diff --git a/.github/workflows/auto-create-release-pr.yml.disabled b/.github/workflows/auto-create-release-pr.yml
similarity index 100%
rename from .github/workflows/auto-create-release-pr.yml.disabled
rename to .github/workflows/auto-create-release-pr.yml
diff --git a/.github/workflows/run-e2e-smoke-tests-android.yml b/.github/workflows/run-e2e-smoke-tests-android.yml
index ea9e35aa3c9c..9d9dd2988b53 100644
--- a/.github/workflows/run-e2e-smoke-tests-android.yml
+++ b/.github/workflows/run-e2e-smoke-tests-android.yml
@@ -141,7 +141,7 @@ jobs:
fail-fast: false
uses: ./.github/workflows/run-e2e-workflow.yml
with:
- test-suite-name: prediction_market_android_smoke-${{ matrix.split }}
+ test-suite-name: prediction-market-android-smoke-${{ matrix.split }}
platform: android
test_suite_tag: 'SmokePredictions'
split_number: ${{ matrix.split }}
diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml
index 3265ab8d1a2c..710b89ecbe1b 100644
--- a/.github/workflows/run-e2e-smoke-tests-ios.yml
+++ b/.github/workflows/run-e2e-smoke-tests-ios.yml
@@ -141,7 +141,7 @@ jobs:
fail-fast: false
uses: ./.github/workflows/run-e2e-workflow.yml
with:
- test-suite-name: prediction_market_ios_smoke-${{ matrix.split }}
+ test-suite-name: prediction-market-ios-smoke-${{ matrix.split }}
platform: ios
test_suite_tag: 'SmokePredictions'
split_number: ${{ matrix.split }}
diff --git a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx
index 223359a47513..2680330faab8 100644
--- a/app/component-library/components/Navigation/TabBar/TabBar.test.tsx
+++ b/app/component-library/components/Navigation/TabBar/TabBar.test.tsx
@@ -30,11 +30,6 @@ interface TestDescriptors {
[key: string]: TestTabDescriptor;
}
-// Force rewards feature flag to be enabled for this test file
-jest.mock('../../../../selectors/featureFlagController/rewards', () => ({
- selectRewardsEnabledFlag: () => true,
-}));
-
// Mock trending tokens feature flag selector
jest.mock('../../../../selectors/featureFlagController/assetsTrendingTokens');
diff --git a/app/component-library/components/Navigation/TabBar/TabBar.tsx b/app/component-library/components/Navigation/TabBar/TabBar.tsx
index ee07838913aa..a620ca4ab10e 100644
--- a/app/component-library/components/Navigation/TabBar/TabBar.tsx
+++ b/app/component-library/components/Navigation/TabBar/TabBar.tsx
@@ -27,14 +27,12 @@ import {
LABEL_BY_TAB_BAR_ICON_KEY,
} from './TabBar.constants';
import { selectChainId } from '../../../../selectors/networkController';
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { selectAssetsTrendingTokensEnabled } from '../../../../selectors/featureFlagController/assetsTrendingTokens';
const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
const { trackEvent, createEventBuilder } = useMetrics();
const { bottom: bottomInset } = useSafeAreaInsets();
const chainId = useSelector(selectChainId);
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
const isAssetsTrendingTokensEnabled = useSelector(
selectAssetsTrendingTokensEnabled,
);
@@ -86,9 +84,7 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
navigation.navigate(Routes.TRANSACTIONS_VIEW);
break;
case Routes.REWARDS_VIEW:
- if (isRewardsEnabled) {
- navigation.navigate(Routes.REWARDS_VIEW);
- }
+ navigation.navigate(Routes.REWARDS_VIEW);
break;
case Routes.SETTINGS_VIEW:
navigation.navigate(Routes.SETTINGS_VIEW, {
@@ -127,7 +123,6 @@ const TabBar = ({ state, descriptors, navigation }: TabBarProps) => {
trackEvent,
createEventBuilder,
tw,
- isRewardsEnabled,
isAssetsTrendingTokensEnabled,
],
);
diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js
index 703a68aad012..08b3bc785d31 100644
--- a/app/components/Nav/Main/MainNavigator.js
+++ b/app/components/Nav/Main/MainNavigator.js
@@ -107,7 +107,6 @@ import {
PredictModalStack,
selectPredictEnabledFlag,
} from '../../UI/Predict';
-import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards';
import { selectAssetsTrendingTokensEnabled } from '../../../selectors/featureFlagController/assetsTrendingTokens';
import PerpsPositionTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView';
import PerpsOrderTransactionView from '../../UI/Perps/Views/PerpsTransactionsView/PerpsOrderTransactionView';
@@ -526,7 +525,6 @@ const HomeTabs = () => {
const [isKeyboardHidden, setIsKeyboardHidden] = useState(true);
const accountsLength = useSelector(selectAccountsLength);
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
const rewardsSubscription = useSelector(selectRewardsSubscriptionId);
const isAssetsTrendingTokensEnabled = useSelector(
selectAssetsTrendingTokensEnabled,
@@ -649,11 +647,7 @@ const HomeTabs = () => {
const currentRoute = state.routes[state.index];
// Hide tab bar for rewards onboarding splash screen
- if (
- currentRoute.name?.startsWith('Rewards') &&
- isRewardsEnabled &&
- !rewardsSubscription
- ) {
+ if (currentRoute.name?.startsWith('Rewards') && !rewardsSubscription) {
return null;
}
@@ -715,21 +709,12 @@ const HomeTabs = () => {
component={TransactionsHome}
layout={({ children }) => {children}}
/>
- {isRewardsEnabled ? (
- UnmountOnBlurComponent(children)}
- />
- ) : (
- UnmountOnBlurComponent(children)}
- />
- )}
+ UnmountOnBlurComponent(children)}
+ />
);
};
@@ -948,7 +933,6 @@ const MainNavigator = () => {
const { enabled: isSendRedesignEnabled } = useSelector(
selectSendRedesignFlags,
);
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
return (
{
component={ConfirmAddAsset}
options={{ headerShown: true }}
/>
- {isRewardsEnabled && (
- ({
- cardStyle: {
- transform: [
- {
- translateX: current.progress.interpolate({
- inputRange: [0, 1],
- outputRange: [layouts.screen.width, 0],
- }),
- },
- ],
- },
- }),
- }}
- />
- )}
+ ({
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ },
+ }),
+ }}
+ />
diff --git a/app/components/Nav/Main/MainNavigator.test.js b/app/components/Nav/Main/MainNavigator.test.js
index 91db9aac03ea..cb5d0825f7b7 100644
--- a/app/components/Nav/Main/MainNavigator.test.js
+++ b/app/components/Nav/Main/MainNavigator.test.js
@@ -12,18 +12,14 @@ jest.mock('./MainNavigator', () => {
const {
TabBarIconKey,
} = require('../../../component-library/components/Navigation/TabBar/TabBar.types');
- const {
- selectRewardsEnabledFlag,
- } = require('../../../selectors/featureFlagController/rewards');
const {
selectAssetsTrendingTokensEnabled,
} = require('../../../selectors/featureFlagController/assetsTrendingTokens');
const { selectBrowserFullscreen } = require('../../../selectors/browser');
const Routes = require('../../../constants/navigation/Routes').default;
- // Mock implementation that tests tab visibility based on rewards flag and browser fullscreen state
+ // Mock implementation that tests tab visibility based on browser fullscreen state
return function MockMainNavigator({ route }) {
- const isRewardsEnabled = selectRewardsEnabledFlag();
const isTrendingEnabled = selectAssetsTrendingTokensEnabled();
const isBrowserFullscreen = selectBrowserFullscreen();
@@ -62,21 +58,11 @@ jest.mock('./MainNavigator', () => {
}),
);
- // Add Rewards tab if enabled
- if (isRewardsEnabled) {
- tabs.push(
- React.createElement(View, {
- key: 'rewards',
- testID: `tab-bar-item-${TabBarIconKey.Rewards}`,
- }),
- );
- }
-
- // Add Settings tab (always shown at the end)
+ // Add Rewards tab
tabs.push(
React.createElement(View, {
- key: 'settings',
- testID: `tab-bar-item-${TabBarIconKey.Setting}`,
+ key: 'rewards',
+ testID: `tab-bar-item-${TabBarIconKey.Rewards}`,
}),
);
@@ -86,7 +72,6 @@ jest.mock('./MainNavigator', () => {
// Mock the rewards selector
jest.mock('../../../selectors/featureFlagController/rewards', () => ({
- selectRewardsEnabledFlag: jest.fn(),
selectRewardsSubscriptionId: jest.fn().mockReturnValue(null),
}));
@@ -103,7 +88,6 @@ jest.mock('../../../selectors/browser', () => ({
selectBrowserFullscreen: jest.fn(),
}));
-import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards';
import { selectAssetsTrendingTokensEnabled } from '../../../selectors/featureFlagController/assetsTrendingTokens';
import { selectBrowserFullscreen } from '../../../selectors/browser';
import MainNavigator from './MainNavigator';
@@ -111,13 +95,11 @@ import MainNavigator from './MainNavigator';
describe('MainNavigator', () => {
beforeEach(() => {
jest.clearAllMocks();
- selectRewardsEnabledFlag.mockReturnValue(false);
selectAssetsTrendingTokensEnabled.mockReturnValue(false);
selectBrowserFullscreen.mockReturnValue(false);
});
it('shows Browser tab when trending feature flag is off', () => {
- selectRewardsEnabledFlag.mockReturnValue(false);
selectAssetsTrendingTokensEnabled.mockReturnValue(false);
const { getByTestId, queryByTestId } = render();
@@ -126,11 +108,10 @@ describe('MainNavigator', () => {
expect(queryByTestId('tab-bar-item-Trending')).toBeNull();
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
+ expect(getByTestId('tab-bar-item-Rewards')).toBeDefined();
});
it('shows Trending tab and hides Browser tab when trending feature flag is on', () => {
- selectRewardsEnabledFlag.mockReturnValue(false);
selectAssetsTrendingTokensEnabled.mockReturnValue(true);
const { getByTestId, queryByTestId } = render();
@@ -139,37 +120,20 @@ describe('MainNavigator', () => {
expect(queryByTestId('tab-bar-item-Browser')).toBeNull();
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
- });
-
- it('shows Settings tab when rewards feature flag is off', () => {
- selectRewardsEnabledFlag.mockReturnValue(false);
- selectAssetsTrendingTokensEnabled.mockReturnValue(false);
-
- const { getByTestId, queryByTestId } = render();
-
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
- expect(queryByTestId('tab-bar-item-Rewards')).toBeNull();
- expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
- expect(getByTestId('tab-bar-item-Browser')).toBeDefined();
- expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
+ expect(getByTestId('tab-bar-item-Rewards')).toBeDefined();
});
- it('shows Rewards tab when rewards feature flag is on', () => {
- selectRewardsEnabledFlag.mockReturnValue(true);
- selectAssetsTrendingTokensEnabled.mockReturnValue(false);
-
+ it('should show Rewards tab', () => {
const { getByTestId } = render();
expect(getByTestId('tab-bar-item-Rewards')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
+ // Verify other core tabs are present
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Browser')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
});
it('shows Trending and Rewards tabs and hides Browser tab when both feature flags are on', () => {
- selectRewardsEnabledFlag.mockReturnValue(true);
selectAssetsTrendingTokensEnabled.mockReturnValue(true);
const { getByTestId, queryByTestId } = render();
@@ -179,7 +143,6 @@ describe('MainNavigator', () => {
expect(queryByTestId('tab-bar-item-Browser')).toBeNull();
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
});
it('should show navbar tabs when browser is not in fullscreen mode', () => {
@@ -193,7 +156,7 @@ describe('MainNavigator', () => {
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Browser')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
+ expect(getByTestId('tab-bar-item-Rewards')).toBeDefined();
});
it('should not show navbar when browser is in fullscreen mode', () => {
@@ -209,7 +172,7 @@ describe('MainNavigator', () => {
expect(queryByTestId('tab-bar-item-Wallet')).toBeNull();
expect(queryByTestId('tab-bar-item-Browser')).toBeNull();
expect(queryByTestId('tab-bar-item-Trade')).toBeNull();
- expect(queryByTestId('tab-bar-item-Setting')).toBeNull();
+ expect(queryByTestId('tab-bar-item-Rewards')).toBeNull();
});
it('should show navbar tabs when browser is in fullscreen mode but on non-browser route', () => {
@@ -225,7 +188,7 @@ describe('MainNavigator', () => {
expect(getByTestId('tab-bar-item-Wallet')).toBeDefined();
expect(getByTestId('tab-bar-item-Browser')).toBeDefined();
expect(getByTestId('tab-bar-item-Trade')).toBeDefined();
- expect(getByTestId('tab-bar-item-Setting')).toBeDefined();
+ expect(getByTestId('tab-bar-item-Rewards')).toBeDefined();
});
it('should return null when isBrowserFullscreen is true AND route starts with BrowserTabHome', () => {
diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap
index d3b7b04c55c1..a4f509330f24 100644
--- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap
+++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.js.snap
@@ -17,7 +17,7 @@ exports[`MainNavigator should match snapshot when browser is not infullscreen mo
testID="tab-bar-item-Activity"
/>
`;
diff --git a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
index a28adbe9dfc0..03b93bc231c7 100644
--- a/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
+++ b/app/components/Nav/Main/__snapshots__/MainNavigator.test.tsx.snap
@@ -61,6 +61,17 @@ exports[`MainNavigator matches rendered snapshot 1`] = `
}
}
/>
+
{
'../../../../../util/test/keyringControllerTestUtils',
);
return {
+ controllerMessenger: {
+ call: jest.fn(),
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
+ },
context: {
SwapsController: {
fetchAggregatorMetadataWithCache: jest.fn(),
@@ -272,6 +277,21 @@ jest.mock('../../../../../util/address', () => ({
isHardwareAccount: jest.fn(),
}));
+jest.mock('react-native-fade-in-image', () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ children,
+ placeholderStyle,
+ }: {
+ children: React.ReactNode;
+ placeholderStyle?: unknown;
+ }) => React.createElement(View, { style: placeholderStyle }, children),
+ };
+});
+
describe('BridgeView', () => {
const token2Address = '0x0000000000000000000000000000000000000002' as Hex;
diff --git a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
index 5b8d1c5d2f63..b9de503587f9 100644
--- a/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
+++ b/app/components/UI/Bridge/Views/BridgeView/__snapshots__/BridgeView.test.tsx.snap
@@ -476,9 +476,7 @@ exports[`BridgeView Bottom Content blurs input when opening QuoteExpiredModal 1`
testID="badge-wrapper-badge"
>
-
+
-
-
-
-
+
-
-
-
{
};
});
+jest.mock('react-native-fade-in-image', () => {
+ const React = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+ return {
+ __esModule: true,
+ default: ({
+ children,
+ placeholderStyle,
+ }: {
+ children: React.ReactNode;
+ placeholderStyle?: unknown;
+ }) => React.createElement(View, { style: placeholderStyle }, children),
+ };
+});
+
const mockNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
@@ -67,9 +82,33 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({
jest.mock('../../../../../core/Engine', () => ({
controllerMessenger: {
call: jest.fn(),
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
},
}));
+// Mock formatChainIdToCaip for AddRewardsAccount component
+jest.mock('@metamask/bridge-controller', () => ({
+ ...jest.requireActual('@metamask/bridge-controller'),
+ formatChainIdToCaip: jest.fn((chainId: string) => {
+ // If already in CAIP format, return as-is
+ if (chainId.includes(':')) {
+ return chainId as `${string}:${string}`;
+ }
+ // Otherwise, convert to CAIP format
+ return `eip155:${chainId}` as `${string}:${string}`;
+ }),
+}));
+
+// Mock useLinkAccountAddress for AddRewardsAccount component
+jest.mock('../../../../UI/Rewards/hooks/useLinkAccountAddress', () => ({
+ useLinkAccountAddress: jest.fn(() => ({
+ linkAccountAddress: jest.fn(),
+ isLoading: false,
+ isError: false,
+ })),
+}));
+
// Mock the bridge selectors
jest.mock('../../../../../core/redux/slices/bridge', () => ({
...jest.requireActual('../../../../../core/redux/slices/bridge'),
@@ -486,6 +525,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -516,6 +558,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -568,30 +613,42 @@ describe('QuoteDetailsCard', () => {
});
});
- it('does not display rewards row when user has not opted in', async () => {
+ it('displays AddRewardsAccount when user has not opted in', async () => {
// Given rewards feature is enabled but user has not opted in
mockEngine.controllerMessenger.call.mockImplementation(
(method: string) => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(false);
}
+ if (method === 'RewardsController:isOptInSupported') {
+ return Promise.resolve(true);
+ }
return Promise.resolve(null);
},
);
// When rendering the component
- const { queryByText } = renderScreen(
+ const { getByText, getByTestId, queryByTestId } = renderScreen(
QuoteDetailsCard,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
);
- // Then the rewards row should not be displayed
+ // Then the rewards row should be displayed
await waitFor(() => {
- expect(queryByText(strings('bridge.points'))).toBeNull();
+ expect(getByText(strings('bridge.points'))).toBeOnTheScreen();
+ });
+
+ // And AddRewardsAccount should be shown instead of RewardsAnimations
+ await waitFor(() => {
+ expect(getByTestId('bridge-add-rewards-account')).toBeOnTheScreen();
+ expect(queryByTestId('mock-rive-animation')).toBeNull();
});
});
@@ -602,6 +659,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -632,6 +692,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -668,6 +731,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -702,6 +768,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -733,6 +802,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -764,6 +836,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -812,6 +887,9 @@ describe('QuoteDetailsCard', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -826,7 +904,7 @@ describe('QuoteDetailsCard', () => {
);
// When rendering the component
- const { getByText } = renderScreen(
+ const { getByText, getByTestId } = renderScreen(
QuoteDetailsCard,
{ name: Routes.BRIDGE.ROOT },
{ state: testState },
@@ -834,6 +912,7 @@ describe('QuoteDetailsCard', () => {
// Rewards row should be shown
await waitFor(() => {
+ expect(getByTestId('bridge-rewards-row')).toBeOnTheScreen();
expect(getByText(strings('bridge.points'))).toBeOnTheScreen();
});
diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
index 3d1413fb3900..e6f319775b39 100644
--- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
+++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx
@@ -34,6 +34,7 @@ import { useRewards } from '../../hooks/useRewards';
import RewardsAnimations, {
RewardAnimationState,
} from '../../../Rewards/components/RewardPointsAnimation';
+import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount';
import QuoteCountdownTimer from '../QuoteCountdownTimer';
import QuoteDetailsRecipientKeyValueRow from '../QuoteDetailsRecipientKeyValueRow/QuoteDetailsRecipientKeyValueRow';
import { toSentenceCase } from '../../../../../util/string';
@@ -69,6 +70,7 @@ const QuoteDetailsCard: React.FC = () => {
isLoading: isRewardsLoading,
shouldShowRewardsRow,
hasError: hasRewardsError,
+ accountOptedIn,
} = useRewards({
activeQuote,
isQuoteLoading,
@@ -270,51 +272,57 @@ const QuoteDetailsCard: React.FC = () => {
{/* Estimated Points */}
{shouldShowRewardsRow && (
-
-
-
- ),
- ...(hasRewardsError && {
+
+
+ }}
+ value={{
+ label: (
+
+ {accountOptedIn ? (
+
+ ) : (
+
+ )}
+
+ ),
+ ...(hasRewardsError && {
+ tooltip: {
+ title: strings('bridge.points_error'),
+ content: strings('bridge.points_error_content'),
+ size: TooltipSizes.Sm,
+ iconName: IconName.Info,
+ },
+ }),
+ }}
+ />
+
)}
diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts
index d2d00ca4ae4d..35ff166f366f 100644
--- a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts
+++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts
@@ -10,6 +10,8 @@ import { CaipAssetType, Hex } from '@metamask/utils';
jest.mock('../../../../../core/Engine', () => ({
controllerMessenger: {
call: jest.fn(),
+ subscribe: jest.fn(),
+ unsubscribe: jest.fn(),
},
}));
@@ -222,6 +224,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: null,
});
});
@@ -232,14 +235,20 @@ describe('useRewards', () => {
});
describe('when user has not opted in', () => {
- it('should return default state when user has not opted in', async () => {
+ it('should return default state when user has not opted in and opt-in is not supported', async () => {
mockCall.mockImplementation((method) => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(false);
}
+ if (method === 'RewardsController:isOptInSupported') {
+ return Promise.resolve(false);
+ }
return Promise.resolve(null);
});
@@ -266,13 +275,78 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: false,
});
});
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:getFirstSubscriptionId',
+ );
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:getHasAccountOptedIn',
+ 'eip155:1:0x1234567890123456789012345678901234567890',
+ );
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:isOptInSupported',
+ expect.any(Object),
+ );
+ });
+
+ it('should show rewards row when user has not opted in but opt-in is supported', async () => {
+ mockCall.mockImplementation((method) => {
+ if (method === 'RewardsController:isRewardsFeatureEnabled') {
+ return Promise.resolve(true);
+ }
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
+ if (method === 'RewardsController:getHasAccountOptedIn') {
+ return Promise.resolve(false);
+ }
+ if (method === 'RewardsController:isOptInSupported') {
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(null);
+ });
+
+ const testState = createBridgeTestState({
+ bridgeReducerOverrides: {
+ sourceToken: defaultSourceToken,
+ destToken: defaultDestToken,
+ sourceAmount: '1',
+ },
+ });
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useRewards({
+ activeQuote: mockActiveQuote,
+ isQuoteLoading: false,
+ }),
+ { state: testState },
+ );
+
+ await waitFor(() => {
+ expect(result.current).toEqual({
+ shouldShowRewardsRow: true,
+ isLoading: false,
+ estimatedPoints: null,
+ hasError: false,
+ accountOptedIn: false,
+ });
+ });
+
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:getFirstSubscriptionId',
+ );
expect(mockCall).toHaveBeenCalledWith(
'RewardsController:getHasAccountOptedIn',
'eip155:1:0x1234567890123456789012345678901234567890',
);
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:isOptInSupported',
+ expect.any(Object),
+ );
});
});
@@ -282,6 +356,9 @@ describe('useRewards', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -314,6 +391,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: 100,
hasError: false,
+ accountOptedIn: true,
});
});
@@ -348,6 +426,9 @@ describe('useRewards', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -417,6 +498,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: null,
});
// Should not call Engine methods
@@ -446,6 +528,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: null,
});
});
@@ -472,6 +555,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: null,
});
});
@@ -498,16 +582,72 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: false,
+ accountOptedIn: null,
});
});
});
+ describe('when subscription ID is missing', () => {
+ it('should return default state when there is no subscription', async () => {
+ mockCall.mockImplementation((method) => {
+ if (method === 'RewardsController:isRewardsFeatureEnabled') {
+ return Promise.resolve(true);
+ }
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve(null);
+ }
+ return Promise.resolve(null);
+ });
+
+ const testState = createBridgeTestState({
+ bridgeReducerOverrides: {
+ sourceToken: defaultSourceToken,
+ destToken: defaultDestToken,
+ sourceAmount: '1',
+ },
+ });
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useRewards({
+ activeQuote: mockActiveQuote,
+ isQuoteLoading: false,
+ }),
+ { state: testState },
+ );
+
+ await waitFor(() => {
+ expect(result.current).toEqual({
+ shouldShowRewardsRow: false,
+ isLoading: false,
+ estimatedPoints: null,
+ hasError: false,
+ accountOptedIn: null,
+ });
+ });
+
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:isRewardsFeatureEnabled',
+ );
+ expect(mockCall).toHaveBeenCalledWith(
+ 'RewardsController:getFirstSubscriptionId',
+ );
+ expect(mockCall).not.toHaveBeenCalledWith(
+ 'RewardsController:getHasAccountOptedIn',
+ expect.any(String),
+ );
+ });
+ });
+
describe('error handling', () => {
it('should handle rewards estimation error gracefully', async () => {
mockCall.mockImplementation((method) => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -540,6 +680,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: true,
+ accountOptedIn: true,
});
});
});
@@ -575,6 +716,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: true,
+ accountOptedIn: null,
});
});
});
@@ -584,6 +726,9 @@ describe('useRewards', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
throw new Error('Opt-in check failed');
}
@@ -613,6 +758,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: null,
hasError: true,
+ accountOptedIn: null,
});
});
});
@@ -623,6 +769,9 @@ describe('useRewards', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -659,6 +808,9 @@ describe('useRewards', () => {
if (method === 'RewardsController:isRewardsFeatureEnabled') {
return Promise.resolve(true);
}
+ if (method === 'RewardsController:getFirstSubscriptionId') {
+ return Promise.resolve('subscription-id-1');
+ }
if (method === 'RewardsController:getHasAccountOptedIn') {
return Promise.resolve(true);
}
@@ -687,6 +839,7 @@ describe('useRewards', () => {
isLoading: false,
estimatedPoints: 100,
hasError: false,
+ accountOptedIn: true,
});
});
});
diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts
index 97e0ee14f856..c191efcf93f6 100644
--- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts
+++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts
@@ -68,6 +68,7 @@ interface UseRewardsResult {
isLoading: boolean;
estimatedPoints: number | null;
hasError: boolean;
+ accountOptedIn: boolean | null;
}
/**
@@ -98,6 +99,7 @@ export const useRewards = ({
const [estimatedPoints, setEstimatedPoints] = useState(null);
const [shouldShowRewardsRow, setShouldShowRewardsRow] = useState(false);
const [hasError, setHasError] = useState(false);
+ const [accountOptedIn, setAccountOptedIn] = useState(null);
const prevRequestId = usePrevious(activeQuote?.quote?.requestId);
// Selectors
@@ -143,6 +145,22 @@ export const useRewards = ({
if (!isRewardsEnabled) {
setEstimatedPoints(null);
+ setShouldShowRewardsRow(false);
+ setAccountOptedIn(null);
+ setIsLoading(false);
+ return;
+ }
+
+ // Check if there's a subscription first
+ const firstSubscriptionId = await Engine.controllerMessenger.call(
+ 'RewardsController:getFirstSubscriptionId',
+ );
+
+ if (!firstSubscriptionId) {
+ setEstimatedPoints(null);
+ setShouldShowRewardsRow(false);
+ setAccountOptedIn(null);
+ setHasError(false);
setIsLoading(false);
return;
}
@@ -155,6 +173,8 @@ export const useRewards = ({
if (!caipAccount) {
setEstimatedPoints(null);
+ setShouldShowRewardsRow(false);
+ setAccountOptedIn(null);
setIsLoading(false);
return;
}
@@ -165,14 +185,29 @@ export const useRewards = ({
caipAccount,
);
+ setAccountOptedIn(hasOptedIn);
+
+ // Determine if we should show the rewards row
+ // Show row if: opted in OR (not opted in AND opt-in is supported)
+ let shouldShow = hasOptedIn;
+
+ if (!hasOptedIn && selectedAccount) {
+ const isOptInSupported = await Engine.controllerMessenger.call(
+ 'RewardsController:isOptInSupported',
+ selectedAccount,
+ );
+ shouldShow = isOptInSupported;
+ }
+
+ setShouldShowRewardsRow(shouldShow);
+
if (!hasOptedIn) {
setEstimatedPoints(null);
+ setHasError(false);
setIsLoading(false);
return;
}
- setShouldShowRewardsRow(true);
-
// Convert source amount to atomic unit
const atomicSourceAmount = activeQuote.quote.srcTokenAmount;
@@ -236,12 +271,13 @@ export const useRewards = ({
setIsLoading(false);
}
}, [
- activeQuote,
+ activeQuote?.quote,
sourceToken,
destToken,
sourceAmount,
selectedAddress,
sourceChainId,
+ selectedAccount,
]);
// Estimate points when dependencies change
@@ -256,10 +292,30 @@ export const useRewards = ({
prevRequestId,
]);
+ // Subscribe to account linked event to retrigger estimate
+ useEffect(() => {
+ const handleAccountLinked = () => {
+ estimatePoints();
+ };
+
+ Engine.controllerMessenger.subscribe(
+ 'RewardsController:accountLinked',
+ handleAccountLinked,
+ );
+
+ return () => {
+ Engine.controllerMessenger.unsubscribe(
+ 'RewardsController:accountLinked',
+ handleAccountLinked,
+ );
+ };
+ }, [estimatePoints]);
+
return {
shouldShowRewardsRow,
isLoading: isLoading || isQuoteLoading,
estimatedPoints,
hasError,
+ accountOptedIn,
};
};
diff --git a/app/components/UI/Bridge/utils/transaction-history.test.ts b/app/components/UI/Bridge/utils/transaction-history.test.ts
index 5a7710046ac5..ad3ec7b132f4 100644
--- a/app/components/UI/Bridge/utils/transaction-history.test.ts
+++ b/app/components/UI/Bridge/utils/transaction-history.test.ts
@@ -391,7 +391,7 @@ describe('decodeSwapsTx', () => {
renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452',
actionKey: 'Swap USDC to ETH',
notificationKey: 'Swap complete (USDC to ETH)',
- value: '-5.0 USDC',
+ value: '5 USDC',
fiatValue: '$5.01',
transactionType: 'swaps_transaction',
},
@@ -399,12 +399,12 @@ describe('decodeSwapsTx', () => {
renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452',
renderTo: '0x881d40237659c251811cec9c364ef91dc08d300c',
hash: '0xac561978ed01a8828e30c193c8368b0baec0f8c8c85c933c324c06352a16aeb6',
- renderValue: '5.0 USDC',
+ renderValue: '5 USDC',
renderGas: 264667,
renderGasPrice: undefined,
renderTotalGas: '0.00053 ETH',
txChainId: '0x1',
- summaryAmount: '5.0 USDC',
+ summaryAmount: '5 USDC',
summaryFee: '0.00053 ETH',
summaryTotalAmount: '5.00053 ETH',
summarySecondaryTotalAmount: '$6.33',
@@ -638,7 +638,8 @@ describe('decodeBridgeTx', () => {
renderTo: '0x0439e60f02a8900a951603950d8d4527f400c3f1',
renderFrom: '0xc5fe6ef47965741f6f7a4734bf784bf3ae3f2452',
actionKey: 'Bridge to Optimism',
- value: '-0.00099125 ETH',
+ notificationKey: undefined,
+ value: '-0.00099 ETH',
fiatValue: '$2.49',
transactionType: 'bridge_transaction',
},
diff --git a/app/components/UI/Bridge/utils/transaction-history.ts b/app/components/UI/Bridge/utils/transaction-history.ts
index b79ec88398c7..d272b9776bf8 100644
--- a/app/components/UI/Bridge/utils/transaction-history.ts
+++ b/app/components/UI/Bridge/utils/transaction-history.ts
@@ -19,6 +19,7 @@ import {
balanceToFiatNumber,
weiToFiatNumber,
weiToFiat,
+ formatAmountWithThreshold,
} from '../../../../util/number';
import { Hex } from '@metamask/utils';
import { ethers } from 'ethers';
@@ -72,10 +73,14 @@ export const decodeBridgeTx = (args: {
const { quote } = bridgeTxHistoryItem;
const sourceTokenSymbol = quote.srcAsset?.symbol;
- const sourceAmountSent = ethers.utils.formatUnits(
- bridgeTxHistoryItem.quote.srcTokenAmount,
- quote.srcAsset.decimals,
+ const rawSourceAmount = parseFloat(
+ ethers.utils.formatUnits(
+ bridgeTxHistoryItem.quote.srcTokenAmount,
+ quote.srcAsset.decimals,
+ ),
);
+ const sourceAmountSent = formatAmountWithThreshold(rawSourceAmount, 5);
+
const renderTo = tx.txParams.to;
const renderFrom = tx.txParams.from;
@@ -85,7 +90,7 @@ export const decodeBridgeTx = (args: {
: contractExchangeRates?.[toFormattedAddress(quote.srcAsset.address)]
?.price;
const sourceAmountFiatNumber = balanceToFiatNumber(
- Number(sourceAmountSent),
+ rawSourceAmount,
conversionRate,
sourceExchangeRate,
);
@@ -135,10 +140,14 @@ export const decodeSwapsTx = (args: {
const sourceTokenSymbol = quote.srcAsset?.symbol;
const destTokenSymbol = quote.destAsset?.symbol;
- const sourceAmountSent = ethers.utils.formatUnits(
- bridgeTxHistoryItem.quote.srcTokenAmount,
- quote.srcAsset.decimals,
+ const rawSourceAmount = parseFloat(
+ ethers.utils.formatUnits(
+ bridgeTxHistoryItem.quote.srcTokenAmount,
+ quote.srcAsset.decimals,
+ ),
);
+ const sourceAmountSent = formatAmountWithThreshold(rawSourceAmount, 5);
+
const renderTo = tx.txParams.to;
const renderFrom = tx.txParams.from;
@@ -154,7 +163,7 @@ export const decodeSwapsTx = (args: {
: contractExchangeRates?.[toFormattedAddress(quote.srcAsset.address)]
?.price;
const sourceAmountFiatNumber = balanceToFiatNumber(
- Number(sourceAmountSent),
+ rawSourceAmount,
conversionRate,
sourceExchangeRate,
);
@@ -179,13 +188,13 @@ export const decodeSwapsTx = (args: {
destinationToken: destTokenSymbol,
},
),
- value: `-${sourceAmountSent} ${sourceTokenSymbol}`,
+ value: `${sourceAmountSent} ${sourceTokenSymbol}`,
fiatValue: sourceAmountFiatValue,
transactionType: TRANSACTION_TYPES.SWAPS_TRANSACTION,
};
const summaryTotalAmountNativeToken = `${
- Number(sourceAmountSent) + Number(totalGasDecimalAmount)
+ rawSourceAmount + Number(totalGasDecimalAmount)
} ${gasTokenSymbol}`;
const summaryTotalAmountNativeTokenFiat = addCurrencySymbol(
sourceAmountFiatNumber + weiToFiatNumber(totalGas, conversionRate),
diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx
index c6ca3117b1af..5b7fb0bd6f47 100644
--- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx
+++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.test.tsx
@@ -131,7 +131,7 @@ describe('MultichainBridgeTransactionListItem', () => {
getByText('bridge_transaction_details.bridge_to_chain'),
).toBeTruthy();
expect(getByText('transaction.confirmed')).toBeTruthy();
- expect(getByText('1.0 ETH')).toBeTruthy();
+ expect(getByText('1 ETH')).toBeTruthy();
expect(getByText('Mar 15, 2025')).toBeTruthy();
});
@@ -184,4 +184,52 @@ describe('MultichainBridgeTransactionListItem', () => {
},
);
});
+
+ it('displays less than threshold for very small amounts', () => {
+ const verySmallAmountBridgeHistoryItem = {
+ ...mockBridgeHistoryItem,
+ quote: {
+ ...mockBridgeHistoryItem.quote,
+ srcTokenAmount: '123456789012',
+ srcAsset: {
+ ...mockBridgeHistoryItem.quote.srcAsset,
+ decimals: 18,
+ },
+ },
+ };
+
+ const { getByText } = renderWithProvider(
+ }
+ />,
+ );
+
+ expect(getByText(/< 0\.00001 ETH/)).toBeTruthy();
+ });
+
+ it('caps amount display at 5 decimal places for larger values', () => {
+ const largerAmountBridgeHistoryItem = {
+ ...mockBridgeHistoryItem,
+ quote: {
+ ...mockBridgeHistoryItem.quote,
+ srcTokenAmount: '123456789012345',
+ srcAsset: {
+ ...mockBridgeHistoryItem.quote.srcAsset,
+ decimals: 18,
+ },
+ },
+ };
+
+ const { getByText } = renderWithProvider(
+ }
+ />,
+ );
+
+ expect(getByText(/0\.00012 ETH/)).toBeTruthy();
+ });
});
diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx
index 107f07d6ad02..3111a06a0430 100644
--- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx
+++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx
@@ -22,6 +22,7 @@ import {
handleUnifiedSwapsTxHistoryItemClick,
} from '../Bridge/utils/transaction-history';
import { ethers } from 'ethers';
+import { formatAmountWithThreshold } from '../../../util/number';
const MultichainBridgeTransactionListItem = ({
transaction,
@@ -68,11 +69,15 @@ const MultichainBridgeTransactionListItem = ({
bridgeHistoryItem.status.destChain?.txHash,
);
- const displayAmount = ethers.utils.formatUnits(
- bridgeHistoryItem.quote.srcTokenAmount,
- bridgeHistoryItem.quote.srcAsset.decimals,
+ const rawAmount = parseFloat(
+ ethers.utils.formatUnits(
+ bridgeHistoryItem.quote.srcTokenAmount,
+ bridgeHistoryItem.quote.srcAsset.decimals,
+ ),
);
+ const displayAmount = formatAmountWithThreshold(rawAmount, 5);
+
return (
<>
)}
- {isRewardsEnabled && (
-
- )}
+
}
@@ -2025,7 +2021,6 @@ export const getSettingsNavigationOptions = (
title,
themeColors,
navigation,
- isRewardsEnabled = false,
) => {
const innerStyles = StyleSheet.create({
headerStyle: {
@@ -2047,16 +2042,15 @@ export const getSettingsNavigationOptions = (
{title}
),
- headerRight: () =>
- isRewardsEnabled ? (
- navigation?.goBack()}
- style={innerStyles.accessories}
- testID={NetworksViewSelectorsIDs.CLOSE_ICON}
- />
- ) : null,
+ headerRight: () => (
+ navigation?.goBack()}
+ style={innerStyles.accessories}
+ testID={NetworksViewSelectorsIDs.CLOSE_ICON}
+ />
+ ),
...innerStyles,
};
};
diff --git a/app/components/UI/Navbar/index.test.jsx b/app/components/UI/Navbar/index.test.jsx
index e6056f44a90f..0daf4af93300 100644
--- a/app/components/UI/Navbar/index.test.jsx
+++ b/app/components/UI/Navbar/index.test.jsx
@@ -633,28 +633,44 @@ describe('getSettingsNavigationOptions', () => {
};
describe('Basic Functionality', () => {
- it('should return navigation options object', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('returns navigation options object', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options).toBeDefined();
expect(typeof options).toBe('object');
});
- it('should set headerLeft to null', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('sets headerLeft to null', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options.headerLeft).toBeNull();
});
- it('should return headerTitle as a function', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('returns headerTitle as a function', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options.headerTitle).toBeDefined();
expect(typeof options.headerTitle).toBe('function');
});
- it('should include headerStyle with correct background color', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('includes headerStyle with correct background color', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options.headerStyle).toBeDefined();
expect(options.headerStyle.backgroundColor).toBe(
@@ -662,44 +678,35 @@ describe('getSettingsNavigationOptions', () => {
);
});
- it('should set transparent shadow and elevation', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
-
- expect(options.headerStyle.shadowColor).toBe('transparent');
- expect(options.headerStyle.elevation).toBe(0);
- });
- });
-
- describe('Rewards Enabled Functionality', () => {
- it('should show close button when rewards are enabled', () => {
+ it('sets transparent shadow and elevation', () => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
mockNavigation,
- true,
);
- expect(options.headerRight).toBeDefined();
- expect(typeof options.headerRight).toBe('function');
+ expect(options.headerStyle.shadowColor).toBe('transparent');
+ expect(options.headerStyle.elevation).toBe(0);
});
+ });
- it('should not show close button when rewards are disabled', () => {
+ describe('Close Button Functionality', () => {
+ it('shows close button in headerRight', () => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
mockNavigation,
- false,
);
- expect(options.headerRight()).toBeNull();
+ expect(options.headerRight).toBeDefined();
+ expect(typeof options.headerRight).toBe('function');
});
- it('should call navigation.goBack when close button is pressed', () => {
+ it('calls navigation.goBack when close button is pressed', () => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
mockNavigation,
- true,
);
const HeaderRightComponent = options.headerRight;
@@ -713,24 +720,22 @@ describe('getSettingsNavigationOptions', () => {
expect(mockNavigation.goBack).toHaveBeenCalledTimes(1);
});
- it('should handle missing navigation object gracefully', () => {
+ it('handles missing navigation object gracefully', () => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
null,
- true,
);
expect(options.headerRight).toBeDefined();
expect(typeof options.headerRight).toBe('function');
});
- it('should handle undefined navigation object when rewards enabled', () => {
+ it('handles undefined navigation object gracefully', () => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
undefined,
- true,
);
expect(options.headerRight).toBeDefined();
@@ -739,8 +744,12 @@ describe('getSettingsNavigationOptions', () => {
});
describe('HeaderTitle Component', () => {
- it('should render MorphText component with correct props', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('renders MorphText component with correct props', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
const HeaderTitleComponent = options.headerTitle;
const { getByText, getByTestId } = renderWithProvider(
@@ -751,11 +760,12 @@ describe('getSettingsNavigationOptions', () => {
expect(getByText(mockTitle)).toBeDefined();
});
- it('should display the provided title text', () => {
+ it('displays the provided title text', () => {
const customTitle = 'Custom Settings Title';
const options = getSettingsNavigationOptions(
customTitle,
mockThemeColors,
+ mockNavigation,
);
const HeaderTitleComponent = options.headerTitle;
@@ -768,19 +778,23 @@ describe('getSettingsNavigationOptions', () => {
});
describe('Parameter Validation', () => {
- it('should handle different title types', () => {
+ it('handles different title types', () => {
const titles = ['Settings', 'Privacy & Security', 'Networks', ''];
titles.forEach((title) => {
expect(() => {
- const options = getSettingsNavigationOptions(title, mockThemeColors);
+ const options = getSettingsNavigationOptions(
+ title,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options).toBeDefined();
expect(options.headerTitle).toBeDefined();
}).not.toThrow();
});
});
- it('should handle different theme colors', () => {
+ it('handles different theme colors', () => {
const themeVariations = [
{ background: { default: '#000000' } },
{ background: { default: '#FFFFFF' } },
@@ -789,7 +803,11 @@ describe('getSettingsNavigationOptions', () => {
themeVariations.forEach((theme) => {
expect(() => {
- const options = getSettingsNavigationOptions(mockTitle, theme);
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ theme,
+ mockNavigation,
+ );
expect(options).toBeDefined();
expect(options.headerStyle.backgroundColor).toBe(
theme.background.default,
@@ -798,55 +816,53 @@ describe('getSettingsNavigationOptions', () => {
});
});
- it('should handle undefined or null parameters gracefully', () => {
+ it('handles undefined or null parameters gracefully', () => {
// Test with undefined title
expect(() => {
const options = getSettingsNavigationOptions(
undefined,
mockThemeColors,
+ mockNavigation,
);
expect(options).toBeDefined();
}).not.toThrow();
// Test with null title
- expect(() => {
- const options = getSettingsNavigationOptions(null, mockThemeColors);
- expect(options).toBeDefined();
- }).not.toThrow();
- });
-
- it('should handle new navigation and isRewardsEnabled parameters', () => {
expect(() => {
const options = getSettingsNavigationOptions(
- mockTitle,
+ null,
mockThemeColors,
mockNavigation,
- true,
);
expect(options).toBeDefined();
- expect(options.headerRight).toBeDefined();
}).not.toThrow();
+ });
+ it('handles navigation parameter', () => {
expect(() => {
const options = getSettingsNavigationOptions(
mockTitle,
mockThemeColors,
mockNavigation,
- false,
);
expect(options).toBeDefined();
- expect(options.headerRight()).toBeNull();
+ expect(options.headerRight).toBeDefined();
}).not.toThrow();
});
});
describe('Return Value Structure', () => {
- it('should return object with expected properties', () => {
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ it('returns object with expected properties', () => {
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(options).toMatchObject({
headerLeft: null,
headerTitle: expect.any(Function),
+ headerRight: expect.any(Function),
headerStyle: expect.objectContaining({
backgroundColor: expect.any(String),
shadowColor: 'transparent',
@@ -855,11 +871,19 @@ describe('getSettingsNavigationOptions', () => {
});
});
- it('should maintain consistent structure across different inputs', () => {
- const options1 = getSettingsNavigationOptions('Title 1', mockThemeColors);
- const options2 = getSettingsNavigationOptions('Title 2', {
- background: { default: '#000000' },
- });
+ it('maintains consistent structure across different inputs', () => {
+ const options1 = getSettingsNavigationOptions(
+ 'Title 1',
+ mockThemeColors,
+ mockNavigation,
+ );
+ const options2 = getSettingsNavigationOptions(
+ 'Title 2',
+ {
+ background: { default: '#000000' },
+ },
+ mockNavigation,
+ );
expect(Object.keys(options1)).toEqual(Object.keys(options2));
expect(typeof options1.headerTitle).toBe(typeof options2.headerTitle);
@@ -868,9 +892,13 @@ describe('getSettingsNavigationOptions', () => {
});
describe('Integration', () => {
- it('should work with React Navigation stack', () => {
+ it('works with React Navigation stack', () => {
const Stack = createStackNavigator();
- const options = getSettingsNavigationOptions(mockTitle, mockThemeColors);
+ const options = getSettingsNavigationOptions(
+ mockTitle,
+ mockThemeColors,
+ mockNavigation,
+ );
expect(() => {
renderWithProvider(
diff --git a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
index 44dcb8930c5b..b6e7127f67dd 100644
--- a/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsMarketListView/PerpsMarketListView.test.tsx
@@ -53,10 +53,6 @@ jest.mock('../../hooks/usePerpsOrderFees', () => ({
formatFeeRate: jest.fn((rate) => `${((rate || 0) * 100).toFixed(3)}%`),
}));
-jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({
- selectRewardsEnabledFlag: jest.fn(() => true),
-}));
-
// Mock PerpsMarketBalanceActions dependencies
jest.mock('../../hooks/stream', () => ({
usePerpsLiveAccount: jest.fn(() => ({
@@ -122,7 +118,7 @@ jest.mock('../../hooks', () => ({
navigateToBrowser: jest.fn(),
navigateToActions: jest.fn(),
navigateToActivity: jest.fn(),
- navigateToRewardsOrSettings: jest.fn(),
+ navigateToRewards: jest.fn(),
navigateToMarketDetails: jest.fn(),
navigateToHome: jest.fn(),
navigateToMarketList: jest.fn(),
diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
index b9a11dbb43e4..83573e42d987 100644
--- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
+++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.test.tsx
@@ -7,7 +7,6 @@ import {
waitFor,
} from '@testing-library/react-native';
import React, { useCallback } from 'react';
-import { useSelector } from 'react-redux';
import { TouchableOpacity } from 'react-native';
import { Text } from '@metamask/design-system-react-native';
import { SafeAreaProvider, Metrics } from 'react-native-safe-area-context';
@@ -67,7 +66,6 @@ import {
} from '../../providers/PerpsStreamManager';
import { usePerpsOrderContext } from '../../contexts/PerpsOrderContext';
import PerpsOrderView from './PerpsOrderView';
-import { selectRewardsEnabledFlag } from '../../../../../selectors/featureFlagController/rewards';
jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
@@ -281,10 +279,7 @@ jest.mock('react-redux', () => ({
}),
}));
-// Mock rewards selector
-jest.mock('../../../../../selectors/featureFlagController/rewards', () => ({
- selectRewardsEnabledFlag: jest.fn(() => false),
-}));
+// Rewards feature flag removed - rewards are always enabled
// Mock DevLogger (module appears to use default export with .log())
jest.mock('../../../../../core/SDKConnect/utils/DevLogger', () => {
@@ -1903,15 +1898,8 @@ describe('PerpsOrderView', () => {
});
describe('Rewards Points Row', () => {
- it('should display points row when rewards are enabled and should show', async () => {
- // Arrange - Enable rewards
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('displays points row when rewards should show', async () => {
+ // Arrange - Rewards are always enabled
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 100,
@@ -1931,43 +1919,8 @@ describe('PerpsOrderView', () => {
});
});
- it('should not display points row when rewards are disabled', async () => {
- // Arrange - Disable rewards
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return false;
- }
- return undefined;
- });
-
- (usePerpsRewards as jest.Mock).mockReturnValue({
- shouldShowRewardsRow: false,
- estimatedPoints: undefined,
- isLoading: false,
- hasError: false,
- bonusBips: undefined,
- feeDiscountPercentage: undefined,
- isRefresh: false,
- });
-
- // Act
- render(, { wrapper: TestWrapper });
-
- // Assert
- await waitFor(() => {
- expect(screen.queryByText('perps.estimated_points')).toBeFalsy();
- });
- });
-
- it('should handle points tooltip interaction', async () => {
- // Arrange
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('handles points tooltip interaction', async () => {
+ // Arrange - Rewards are always enabled
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 150,
@@ -1989,15 +1942,8 @@ describe('PerpsOrderView', () => {
expect(screen.getByText('perps.estimated_points')).toBeTruthy();
});
- it('should render RewardsAnimations component with correct props when rewards shown', async () => {
- // Arrange - Enable rewards with specific values
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('renders RewardsAnimations component with correct props when rewards shown', async () => {
+ // Arrange - Rewards are always enabled
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 1000,
@@ -2019,15 +1965,8 @@ describe('PerpsOrderView', () => {
});
});
- it('should render RewardsAnimations in loading state', async () => {
- // Arrange - Enable rewards in loading state
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('renders RewardsAnimations in loading state', async () => {
+ // Arrange - Rewards are always enabled, in loading state
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 0,
@@ -2047,15 +1986,8 @@ describe('PerpsOrderView', () => {
});
});
- it('should render RewardsAnimations in error state', async () => {
- // Arrange - Enable rewards in error state
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('renders RewardsAnimations in error state', async () => {
+ // Arrange - Rewards are always enabled, in error state
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 0,
@@ -2076,15 +2008,8 @@ describe('PerpsOrderView', () => {
});
});
- it('should render RewardsAnimations with bonus bips when provided', async () => {
- // Arrange - Enable rewards with bonus
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
+ it('renders RewardsAnimations with bonus bips when provided', async () => {
+ // Arrange - Rewards are always enabled, with bonus
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
estimatedPoints: 2500,
@@ -2281,16 +2206,8 @@ describe('PerpsOrderView', () => {
});
describe('Points section with rewards', () => {
- it('should display points row and handle tooltip when rewards enabled', async () => {
- // Enable rewards flag
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
- // Mock rewards with points
+ it('displays points row and handles tooltip when rewards should show', async () => {
+ // Arrange - Rewards are always enabled
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
isLoading: false,
@@ -2301,9 +2218,10 @@ describe('PerpsOrderView', () => {
isRefresh: false,
});
+ // Act
render(, { wrapper: TestWrapper });
- // Verify points section is displayed
+ // Assert - Verify points section is displayed
await waitFor(() => {
expect(screen.getByText('perps.estimated_points')).toBeDefined();
});
@@ -2474,16 +2392,8 @@ describe('PerpsOrderView', () => {
});
});
- it('should show rewards state integration with fee discount', async () => {
- // Enable rewards and mock state
- (useSelector as jest.Mock).mockImplementation((selector) => {
- if (selector === selectRewardsEnabledFlag) {
- return true;
- }
- return undefined;
- });
-
- // Mock rewards state with all properties
+ it('shows rewards state integration with fee discount', async () => {
+ // Arrange - Rewards are always enabled
(usePerpsRewards as jest.Mock).mockReturnValue({
shouldShowRewardsRow: true,
isLoading: false,
diff --git a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts
index 451ebeb36e88..97da0adcd31d 100644
--- a/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts
+++ b/app/components/UI/Perps/hooks/stream/useLivePositions.test.ts
@@ -383,7 +383,7 @@ describe('usePerpsLivePositions', () => {
});
});
- it('uses mark price over mid price when available', async () => {
+ it('uses price over mark price when available', async () => {
let positionsCallback: (positions: Position[]) => void = jest.fn();
let pricesCallback: (prices: Record) => void =
jest.fn();
@@ -428,7 +428,7 @@ describe('usePerpsLivePositions', () => {
await waitFor(() => {
const updatedPosition = result.current.positions[0];
- expect(updatedPosition.unrealizedPnl).toBe('1500');
+ expect(updatedPosition.unrealizedPnl).toBe('1000');
});
});
@@ -709,16 +709,22 @@ describe('usePerpsLivePositions', () => {
expect(enriched[0]).toEqual(position);
});
- it('returns position unchanged when margin is NaN', () => {
+ it('calculates PnL even when margin is NaN (uses leverage instead)', () => {
const position: Position = {
...mockPosition,
+ entryPrice: '50000',
+ size: '1.0',
marginUsed: 'invalid',
- unrealizedPnl: '500',
+ leverage: {
+ type: 'isolated',
+ value: 10,
+ },
};
const enriched = enrichPositionsWithLivePnL([position], basePriceData);
- expect(enriched[0]).toEqual(position);
+ expect(enriched[0].unrealizedPnl).toBe('2000');
+ expect(enriched[0].returnOnEquity).toBe('0.4');
});
it('handles multiple positions with mixed price availability', () => {
diff --git a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts
index 488e8731186c..312ffc262905 100644
--- a/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts
+++ b/app/components/UI/Perps/hooks/stream/usePerpsLivePositions.ts
@@ -2,6 +2,7 @@ import { useEffect, useState, useRef } from 'react';
import { usePerpsStream } from '../../providers/PerpsStreamManager';
import { DevLogger } from '../../../../../core/SDKConnect/utils/DevLogger';
import type { Position, PriceUpdate } from '../../controllers/types';
+import { calculateRoEForPrice } from '../../utils/tpslValidation';
// Stable empty array reference to prevent re-renders
const EMPTY_POSITIONS: Position[] = [];
@@ -39,32 +40,41 @@ export function enrichPositionsWithLivePnL(
}
// Use mark price if available, fallback to mid price
- const markPrice = priceUpdate.markPrice
- ? Number.parseFloat(priceUpdate.markPrice)
- : Number.parseFloat(priceUpdate.price);
+ const currentPrice = Number.parseFloat(
+ priceUpdate.price ?? priceUpdate.markPrice,
+ );
- if (!markPrice || Number.isNaN(markPrice) || markPrice <= 0) {
+ if (!currentPrice || Number.isNaN(currentPrice) || currentPrice <= 0) {
return position;
}
const entryPrice = Number.parseFloat(position.entryPrice);
const size = Number.parseFloat(position.size);
- const marginUsed = Number.parseFloat(position.marginUsed);
+ const leverage = position.leverage?.value ?? 1;
- if (
- Number.isNaN(entryPrice) ||
- Number.isNaN(size) ||
- Number.isNaN(marginUsed)
- ) {
+ if (Number.isNaN(entryPrice) || Number.isNaN(size) || entryPrice <= 0) {
return position;
}
- // Calculate unrealized PnL: (markPrice - entryPrice) * size
- const calculatedUnrealizedPnl = (markPrice - entryPrice) * size;
+ const direction = size >= 0 ? 'long' : 'short';
+
+ const calculatedUnrealizedPnl = (currentPrice - entryPrice) * size;
+
+ const roePercentage = calculateRoEForPrice(
+ currentPrice.toString(),
+ calculatedUnrealizedPnl >= 0, // isProfit
+ true, // isForPositionBoundTpsl - true for existing positions
+ {
+ currentPrice,
+ direction,
+ leverage,
+ entryPrice,
+ },
+ );
- // Calculate ROE: (unrealizedPnl / marginUsed) as decimal (not percentage)
- const calculatedRoe =
- marginUsed > 0 ? calculatedUnrealizedPnl / marginUsed : 0;
+ const calculatedRoe = roePercentage
+ ? Number.parseFloat(roePercentage) / 100
+ : 0;
return {
...position,
diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts
index ff2a1f325d5e..1c23ae838086 100644
--- a/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsNavigation.test.ts
@@ -1,6 +1,5 @@
import { renderHook } from '@testing-library/react-hooks';
import { useNavigation } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
import { usePerpsNavigation } from './usePerpsNavigation';
import Routes from '../../../../constants/navigation/Routes';
@@ -8,10 +7,6 @@ jest.mock('@react-navigation/native', () => ({
useNavigation: jest.fn(),
}));
-jest.mock('react-redux', () => ({
- useSelector: jest.fn(),
-}));
-
describe('usePerpsNavigation', () => {
const mockNavigate = jest.fn();
const mockCanGoBack = jest.fn();
@@ -19,9 +14,6 @@ describe('usePerpsNavigation', () => {
const mockUseNavigation = useNavigation as jest.MockedFunction<
typeof useNavigation
>;
- const mockUseSelector = useSelector as jest.MockedFunction<
- typeof useSelector
- >;
beforeEach(() => {
jest.clearAllMocks();
@@ -33,7 +25,6 @@ describe('usePerpsNavigation', () => {
} as Partial> as ReturnType<
typeof useNavigation
>);
- mockUseSelector.mockReturnValue(false); // isRewardsEnabled = false
});
describe('Main App Navigation', () => {
@@ -81,22 +72,10 @@ describe('usePerpsNavigation', () => {
});
});
- it('navigates to settings when rewards disabled', () => {
- mockUseSelector.mockReturnValue(false);
- const { result } = renderHook(() => usePerpsNavigation());
-
- result.current.navigateToRewardsOrSettings();
-
- expect(mockNavigate).toHaveBeenCalledWith(Routes.SETTINGS_VIEW, {
- screen: 'Settings',
- });
- });
-
- it('navigates to rewards when rewards enabled', () => {
- mockUseSelector.mockReturnValue(true);
+ it('navigates to rewards', () => {
const { result } = renderHook(() => usePerpsNavigation());
- result.current.navigateToRewardsOrSettings();
+ result.current.navigateToRewards();
expect(mockNavigate).toHaveBeenCalledWith(Routes.REWARDS_VIEW);
});
diff --git a/app/components/UI/Perps/hooks/usePerpsNavigation.ts b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
index 71b87751c185..8c5d9c9252ee 100644
--- a/app/components/UI/Perps/hooks/usePerpsNavigation.ts
+++ b/app/components/UI/Perps/hooks/usePerpsNavigation.ts
@@ -1,8 +1,6 @@
import { useCallback } from 'react';
import { useNavigation, NavigationProp } from '@react-navigation/native';
-import { useSelector } from 'react-redux';
import Routes from '../../../../constants/navigation/Routes';
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import type { PerpsNavigationParamList } from '../types/navigation';
import type { PerpsMarketData } from '../controllers/types';
@@ -15,7 +13,7 @@ export interface PerpsNavigationHandlers {
navigateToBrowser: () => void;
navigateToActions: () => void;
navigateToActivity: () => void;
- navigateToRewardsOrSettings: () => void;
+ navigateToRewards: () => void;
// Perps-specific navigation
navigateToMarketDetails: (market: PerpsMarketData, source?: string) => void;
@@ -62,7 +60,6 @@ export interface PerpsNavigationHandlers {
*/
export const usePerpsNavigation = (): PerpsNavigationHandlers => {
const navigation = useNavigation>();
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
// Main app navigation handlers
const navigateToWallet = useCallback(() => {
@@ -93,15 +90,9 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
});
}, [navigation]);
- const navigateToRewardsOrSettings = useCallback(() => {
- if (isRewardsEnabled) {
- navigation.navigate(Routes.REWARDS_VIEW);
- } else {
- navigation.navigate(Routes.SETTINGS_VIEW, {
- screen: 'Settings',
- });
- }
- }, [navigation, isRewardsEnabled]);
+ const navigateToRewards = useCallback(() => {
+ navigation.navigate(Routes.REWARDS_VIEW);
+ }, [navigation]);
// Perps-specific navigation handlers
const navigateToMarketDetails = useCallback(
@@ -159,7 +150,7 @@ export const usePerpsNavigation = (): PerpsNavigationHandlers => {
navigateToBrowser,
navigateToActions,
navigateToActivity,
- navigateToRewardsOrSettings,
+ navigateToRewards,
// Perps-specific navigation
navigateToMarketDetails,
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
index 043153da24d0..4f2f9c891ee3 100644
--- a/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.test.ts
@@ -28,11 +28,6 @@ jest.mock('../../../../core/Engine', () => ({
context: mockEngineContext,
}));
-// Mock specific selectors directly
-jest.mock('../../../../selectors/featureFlagController/rewards', () => ({
- selectRewardsEnabledFlag: jest.fn().mockReturnValue(true),
-}));
-
jest.mock('../../../../selectors/accountsController', () => ({
selectSelectedInternalAccountFormattedAddress: jest
.fn()
@@ -424,12 +419,7 @@ describe('usePerpsOrderFees', () => {
expect(result.current.estimatedPoints).toBeUndefined();
});
- it('should handle rewards disabled', async () => {
- const { selectRewardsEnabledFlag } = jest.requireMock(
- '../../../../selectors/featureFlagController/rewards',
- );
- selectRewardsEnabledFlag.mockReturnValue(false);
-
+ it('should handle rewards enabled', async () => {
const mockFeeResult: FeeCalculationResult = {
feeRate: 0.00045,
feeAmount: 45,
diff --git a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts
index e6f65d7172e1..315b971b114b 100644
--- a/app/components/UI/Perps/hooks/usePerpsOrderFees.ts
+++ b/app/components/UI/Perps/hooks/usePerpsOrderFees.ts
@@ -3,7 +3,6 @@ import { useSelector } from 'react-redux';
import Engine from '../../../../core/Engine';
import { DevLogger } from '../../../../core/SDKConnect/utils/DevLogger';
import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController';
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { selectChainId } from '../../../../selectors/networkController';
import { setMeasurement } from '@sentry/react-native';
@@ -113,7 +112,6 @@ export function usePerpsOrderFees({
currentBidPrice,
}: UsePerpsOrderFeesParams): OrderFeesResult {
const { calculateFees } = usePerpsTrading();
- const rewardsEnabled = useSelector(selectRewardsEnabledFlag);
const selectedAddress = useSelector(
selectSelectedInternalAccountFormattedAddress,
);
@@ -153,11 +151,6 @@ export function usePerpsOrderFees({
async (
address: string,
): Promise<{ discountBips?: number; tier?: string }> => {
- // Early return if feature flag is disabled - never make API call
- if (!rewardsEnabled) {
- return {};
- }
-
// Check cache first
const now = Date.now();
if (
@@ -225,7 +218,7 @@ export function usePerpsOrderFees({
return {};
}
},
- [rewardsEnabled, currentChainId],
+ [currentChainId],
);
/**
@@ -239,11 +232,6 @@ export function usePerpsOrderFees({
isClose: boolean,
actualFeeUSD?: number,
): Promise => {
- // Early return if feature flag is disabled - never make API call
- if (!rewardsEnabled) {
- return null;
- }
-
try {
const amountNum = Number.parseFloat(tradeAmount || '0');
if (amountNum <= 0) {
@@ -314,7 +302,7 @@ export function usePerpsOrderFees({
return null;
}
},
- [rewardsEnabled, currentChainId],
+ [currentChainId],
);
// State for fees from provider
@@ -345,7 +333,7 @@ export function usePerpsOrderFees({
*/
const applyFeeDiscount = useCallback(
async (originalRate: number) => {
- if (!rewardsEnabled || !selectedAddress) {
+ if (!selectedAddress) {
return { adjustedRate: originalRate, discountPercentage: undefined };
}
@@ -390,7 +378,7 @@ export function usePerpsOrderFees({
return { adjustedRate: originalRate, discountPercentage: undefined };
}
},
- [rewardsEnabled, fetchFeeDiscount, amount, selectedAddress],
+ [fetchFeeDiscount, amount, selectedAddress],
);
/**
@@ -401,7 +389,7 @@ export function usePerpsOrderFees({
userAddress: string,
actualFeeUSD: number,
): Promise<{ points?: number; bonusBips?: number }> => {
- if (!rewardsEnabled || Number.parseFloat(amount) <= 0) {
+ if (Number.parseFloat(amount) <= 0) {
return {};
}
@@ -491,7 +479,7 @@ export function usePerpsOrderFees({
return {};
}
},
- [rewardsEnabled, amount, coin, isClosing, estimatePoints],
+ [amount, coin, isClosing, estimatePoints],
);
/**
diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts
index 7da5a254e55b..d7a297ae4105 100644
--- a/app/components/UI/Perps/hooks/usePerpsRewards.test.ts
+++ b/app/components/UI/Perps/hooks/usePerpsRewards.test.ts
@@ -2,11 +2,6 @@ import { renderHook, act } from '@testing-library/react-native';
import { usePerpsRewards } from './usePerpsRewards';
import type { OrderFeesResult } from './usePerpsOrderFees';
-// Mock the Redux selector
-jest.mock('react-redux', () => ({
- useSelector: jest.fn(),
-}));
-
// Mock the development config
jest.mock('../constants/perpsConfig', () => ({
DEVELOPMENT_CONFIG: {
@@ -15,9 +10,6 @@ jest.mock('../constants/perpsConfig', () => ({
},
}));
-import { useSelector } from 'react-redux';
-const mockUseSelector = useSelector as jest.MockedFunction;
-
describe('usePerpsRewards', () => {
// Mock fee results for testing
const createMockFeeResults = (
@@ -39,35 +31,11 @@ describe('usePerpsRewards', () => {
beforeEach(() => {
jest.clearAllMocks();
- // Default: rewards enabled
- mockUseSelector.mockReturnValue(true);
});
- describe('Feature flag scenarios', () => {
- it('should not show rewards row when feature flag is disabled', () => {
- // Arrange
- mockUseSelector.mockReturnValue(false);
- const feeResults = createMockFeeResults({ estimatedPoints: 100 });
-
- // Act
- const { result } = renderHook(() =>
- usePerpsRewards({
- feeResults,
- hasValidAmount: true,
- isFeesLoading: false,
- orderAmount: '1000',
- }),
- );
-
- // Assert
- expect(result.current.shouldShowRewardsRow).toBe(false);
- expect(result.current.isLoading).toBe(false);
- expect(result.current.hasError).toBe(false);
- });
-
- it('should show rewards row when feature flag is enabled and has valid amount', () => {
+ describe('Rewards row visibility', () => {
+ it('should show rewards row when has valid amount', () => {
// Arrange
- mockUseSelector.mockReturnValue(true);
const feeResults = createMockFeeResults({ estimatedPoints: 100 });
// Act
@@ -87,7 +55,6 @@ describe('usePerpsRewards', () => {
it('should not show rewards row when hasValidAmount is false', () => {
// Arrange
- mockUseSelector.mockReturnValue(true);
const feeResults = createMockFeeResults({ estimatedPoints: 100 });
// Act
diff --git a/app/components/UI/Perps/hooks/usePerpsRewards.ts b/app/components/UI/Perps/hooks/usePerpsRewards.ts
index a9268dd7809b..e8f28bb4a644 100644
--- a/app/components/UI/Perps/hooks/usePerpsRewards.ts
+++ b/app/components/UI/Perps/hooks/usePerpsRewards.ts
@@ -1,6 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
-import { useSelector } from 'react-redux';
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { DEVELOPMENT_CONFIG } from '../constants/perpsConfig';
import { OrderFeesResult } from './usePerpsOrderFees';
@@ -42,9 +40,6 @@ export const usePerpsRewards = ({
isFeesLoading = false,
orderAmount = '',
}: UsePerpsRewardsParams): UsePerpsRewardsResult => {
- // Get rewards feature flag
- const rewardsEnabled = useSelector(selectRewardsEnabledFlag);
-
// Track previous points to detect refresh state
const [previousPoints, setPreviousPoints] = useState();
@@ -69,8 +64,8 @@ export const usePerpsRewards = ({
// Determine if we should show rewards row
const shouldShowRewardsRow = useMemo(
- () => rewardsEnabled && hasValidAmount, // Show row if we have valid amount (even if there's an error or points are undefined)
- [rewardsEnabled, hasValidAmount],
+ () => hasValidAmount, // Show row if we have valid amount (even if there's an error or points are undefined)
+ [hasValidAmount],
);
// Determine loading state
diff --git a/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx
new file mode 100644
index 000000000000..1dae9899cd01
--- /dev/null
+++ b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.test.tsx
@@ -0,0 +1,516 @@
+import React from 'react';
+import { fireEvent, act } from '@testing-library/react-native';
+import { useSelector } from 'react-redux';
+import { InternalAccount } from '@metamask/keyring-internal-api';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import AddRewardsAccount from './AddRewardsAccount';
+import { useLinkAccountAddress } from '../../hooks/useLinkAccountAddress';
+import { formatChainIdToCaip } from '@metamask/bridge-controller';
+import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
+import { selectSourceToken } from '../../../../../core/redux/slices/bridge';
+
+// Mock dependencies
+jest.mock('react-redux', () => {
+ const actual = jest.requireActual('react-redux');
+ return {
+ ...actual,
+ useSelector: jest.fn(),
+ };
+});
+
+jest.mock('../../hooks/useLinkAccountAddress', () => ({
+ useLinkAccountAddress: jest.fn(),
+}));
+
+jest.mock('@metamask/bridge-controller', () => ({
+ formatChainIdToCaip: jest.fn(),
+}));
+
+jest.mock('../../../../../util/Logger', () => ({
+ log: jest.fn(),
+ error: jest.fn(),
+ warn: jest.fn(),
+}));
+
+jest.mock('@metamask/design-system-twrnc-preset', () => ({
+ useTailwind: jest.fn(() => ({
+ style: jest.fn(() => ({})),
+ })),
+}));
+
+jest.mock('../../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string) => key),
+}));
+
+jest.mock('../../../../../selectors/multichainAccounts/accounts', () => ({
+ selectSelectedInternalAccountByScope: jest.fn(),
+}));
+
+jest.mock('../../../../../core/redux/slices/bridge', () => ({
+ selectSourceToken: jest.fn(),
+}));
+
+// Mock SVG - override the global SVG mock for this specific file
+jest.mock(
+ '../../../../../images/rewards/metamask-rewards-points-alternative.svg',
+ () => {
+ const ReactActual = jest.requireActual('react');
+ const { View } = jest.requireActual('react-native');
+
+ const SvgComponent = ReactActual.forwardRef(
+ (props: Record, ref: unknown) =>
+ ReactActual.createElement(View, {
+ testID: 'metamask-rewards-points-alternative-image',
+ ref,
+ ...props,
+ }),
+ );
+
+ SvgComponent.displayName = 'MetamaskRewardsPointsAlternativeImage';
+
+ return SvgComponent;
+ },
+);
+
+// Mock design system components
+jest.mock('@metamask/design-system-react-native', () => {
+ const ReactActual = jest.requireActual('react');
+ const { View, TouchableOpacity, Text } = jest.requireActual('react-native');
+
+ const Box = ({
+ children,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ [key: string]: unknown;
+ }) => ReactActual.createElement(View, props, children);
+
+ const TextComponent = ({
+ children,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ [key: string]: unknown;
+ }) => ReactActual.createElement(Text, props, children);
+
+ const Button = ({
+ children,
+ onPress,
+ testID,
+ isDisabled,
+ isLoading,
+ startAccessory,
+ ...props
+ }: {
+ children?: React.ReactNode;
+ onPress?: () => void;
+ testID?: string;
+ isDisabled?: boolean;
+ isLoading?: boolean;
+ startAccessory?: React.ReactNode;
+ [key: string]: unknown;
+ }) =>
+ ReactActual.createElement(
+ TouchableOpacity,
+ {
+ onPress,
+ testID,
+ disabled: isDisabled,
+ accessibilityState: {
+ disabled: isDisabled || false,
+ busy: isLoading || false,
+ },
+ ...props,
+ },
+ startAccessory,
+ ReactActual.createElement(Text, {}, children),
+ );
+
+ return {
+ Box,
+ Text: TextComponent,
+ Button,
+ ButtonSize: {
+ Sm: 'sm',
+ },
+ ButtonVariant: {
+ Tertiary: 'tertiary',
+ },
+ };
+});
+
+const mockUseSelector = useSelector as jest.MockedFunction;
+const mockUseLinkAccountAddress = useLinkAccountAddress as jest.MockedFunction<
+ typeof useLinkAccountAddress
+>;
+const mockFormatChainIdToCaip = formatChainIdToCaip as jest.MockedFunction<
+ typeof formatChainIdToCaip
+>;
+const mockSelectSourceToken = selectSourceToken as jest.MockedFunction<
+ typeof selectSourceToken
+>;
+const mockSelectSelectedInternalAccountByScope =
+ selectSelectedInternalAccountByScope as jest.MockedFunction<
+ typeof selectSelectedInternalAccountByScope
+ >;
+
+describe('AddRewardsAccount', () => {
+ const mockLinkAccountAddress = jest.fn();
+ const mockGetSelectedAccountByScope = jest.fn();
+
+ const mockAccount: InternalAccount = {
+ id: 'test-account-id',
+ address: '0x1234567890123456789012345678901234567890',
+ type: 'eip155:eoa',
+ scopes: ['eip155:1'],
+ options: {},
+ methods: [],
+ metadata: {
+ name: 'Test Account',
+ importTime: Date.now(),
+ keyring: {
+ type: 'HD Key Tree',
+ },
+ },
+ };
+
+ const mockSourceToken = {
+ chainId: '0x1',
+ address: '0xTokenAddress',
+ symbol: 'ETH',
+ decimals: 18,
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Default mock implementations
+ mockUseLinkAccountAddress.mockReturnValue({
+ linkAccountAddress: mockLinkAccountAddress,
+ isLoading: false,
+ isError: false,
+ });
+
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === mockSelectSourceToken) {
+ return mockSourceToken;
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return mockGetSelectedAccountByScope;
+ }
+ return undefined;
+ });
+
+ mockFormatChainIdToCaip.mockImplementation(
+ (chainId: string | number) =>
+ `eip155:${parseInt(chainId as string, 16)}` as `${string}:${string}`,
+ );
+
+ mockGetSelectedAccountByScope.mockReturnValue(mockAccount);
+ });
+
+ describe('Rendering', () => {
+ it('renders button when account prop is provided', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
+ });
+
+ it('renders button when accountScope is derived from sourceToken', () => {
+ const { getByTestId } = renderWithProvider();
+
+ expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
+ });
+
+ it('returns null when no accountScope is available', () => {
+ mockGetSelectedAccountByScope.mockReturnValue(undefined);
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('returns null when sourceToken is undefined', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === mockSelectSourceToken) {
+ return undefined;
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return mockGetSelectedAccountByScope;
+ }
+ return undefined;
+ });
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('returns null when sourceToken chainId is undefined', () => {
+ mockUseSelector.mockImplementation((selector) => {
+ if (selector === mockSelectSourceToken) {
+ return { ...mockSourceToken, chainId: undefined };
+ }
+ if (selector === mockSelectSelectedInternalAccountByScope) {
+ return mockGetSelectedAccountByScope;
+ }
+ return undefined;
+ });
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('uses custom testID when provided', () => {
+ const customTestID = 'custom-test-id';
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ expect(getByTestId(customTestID)).toBeOnTheScreen();
+ });
+ });
+
+ describe('Account Scope Resolution', () => {
+ it('uses account prop when provided', () => {
+ const customAccount: InternalAccount = {
+ ...mockAccount,
+ id: 'custom-account-id',
+ address: '0xCustomAddress',
+ };
+
+ renderWithProvider();
+
+ // Verify that linkAccountAddress would be called with custom account
+ // This is tested indirectly through button press
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ fireEvent.press(getByTestId('add-rewards-account'));
+
+ expect(mockLinkAccountAddress).toHaveBeenCalledWith(customAccount);
+ });
+
+ it('derives accountScope from sourceToken when account prop is not provided', () => {
+ const derivedAccount: InternalAccount = {
+ ...mockAccount,
+ id: 'derived-account-id',
+ };
+ mockGetSelectedAccountByScope.mockReturnValue(derivedAccount);
+
+ const { getByTestId } = renderWithProvider();
+
+ fireEvent.press(getByTestId('add-rewards-account'));
+
+ expect(mockFormatChainIdToCaip).toHaveBeenCalledWith('0x1');
+ expect(mockGetSelectedAccountByScope).toHaveBeenCalledWith('eip155:1');
+ expect(mockLinkAccountAddress).toHaveBeenCalledWith(derivedAccount);
+ });
+
+ it('handles formatChainIdToCaip conversion correctly', () => {
+ mockFormatChainIdToCaip.mockReturnValue(
+ 'eip155:137' as `${string}:${string}`,
+ );
+
+ renderWithProvider();
+
+ expect(mockFormatChainIdToCaip).toHaveBeenCalledWith('0x1');
+ expect(mockGetSelectedAccountByScope).toHaveBeenCalledWith('eip155:137');
+ });
+ });
+
+ describe('Button Interactions', () => {
+ it('calls linkAccountAddress when button is pressed', async () => {
+ mockLinkAccountAddress.mockResolvedValue(true);
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId('add-rewards-account'));
+ });
+
+ expect(mockLinkAccountAddress).toHaveBeenCalledWith(mockAccount);
+ });
+
+ it('does not call linkAccountAddress when accountScope is undefined', () => {
+ mockGetSelectedAccountByScope.mockReturnValue(undefined);
+
+ const { queryByTestId } = renderWithProvider();
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ expect(mockLinkAccountAddress).not.toHaveBeenCalled();
+ });
+
+ it('sets isSuccess state when linkAccountAddress succeeds', async () => {
+ mockLinkAccountAddress.mockResolvedValue(true);
+
+ const { getByTestId, queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId('add-rewards-account'));
+ });
+
+ // Component should return null after successful link
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('does not set isSuccess state when linkAccountAddress fails', async () => {
+ mockLinkAccountAddress.mockResolvedValue(false);
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId('add-rewards-account'));
+ });
+
+ // Component should still render after failed link
+ expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
+ });
+ });
+
+ describe('Loading States', () => {
+ it('disables button when isLoading is true', () => {
+ mockUseLinkAccountAddress.mockReturnValue({
+ linkAccountAddress: mockLinkAccountAddress,
+ isLoading: true,
+ isError: false,
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const button = getByTestId('add-rewards-account');
+ expect(button).toHaveProp('disabled', true);
+ });
+
+ it('enables button when isLoading is false', () => {
+ mockUseLinkAccountAddress.mockReturnValue({
+ linkAccountAddress: mockLinkAccountAddress,
+ isLoading: false,
+ isError: false,
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const button = getByTestId('add-rewards-account');
+ expect(button).toHaveProp('disabled', false);
+ });
+
+ it('shows loading state on button when isLoading is true', () => {
+ mockUseLinkAccountAddress.mockReturnValue({
+ linkAccountAddress: mockLinkAccountAddress,
+ isLoading: true,
+ isError: false,
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const button = getByTestId('add-rewards-account');
+ expect(button).toHaveProp('accessibilityState', {
+ disabled: true,
+ busy: true,
+ });
+ });
+ });
+
+ describe('Edge Cases', () => {
+ it('handles accountScope becoming undefined after initial render', () => {
+ const { rerender, queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ expect(queryByTestId('add-rewards-account')).toBeOnTheScreen();
+
+ // Simulate accountScope becoming undefined
+ mockGetSelectedAccountByScope.mockReturnValue(undefined);
+
+ rerender();
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('handles multiple rapid button presses', async () => {
+ mockLinkAccountAddress.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(() => resolve(true), 100);
+ }),
+ );
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ );
+
+ const button = getByTestId('add-rewards-account');
+
+ await act(async () => {
+ fireEvent.press(button);
+ fireEvent.press(button);
+ fireEvent.press(button);
+ });
+
+ // Should only be called once per press, but multiple times total
+ expect(mockLinkAccountAddress).toHaveBeenCalled();
+ });
+ });
+
+ describe('Success State', () => {
+ it('hides component after successful account linking', async () => {
+ mockLinkAccountAddress.mockResolvedValue(true);
+
+ const { getByTestId, queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ expect(getByTestId('add-rewards-account')).toBeOnTheScreen();
+
+ await act(async () => {
+ fireEvent.press(getByTestId('add-rewards-account'));
+ });
+
+ // Wait for state update
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+
+ it('maintains success state across re-renders', async () => {
+ mockLinkAccountAddress.mockResolvedValue(true);
+
+ const { getByTestId, rerender, queryByTestId } = renderWithProvider(
+ ,
+ );
+
+ await act(async () => {
+ fireEvent.press(getByTestId('add-rewards-account'));
+ // Wait for promise to settle
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+
+ // Re-render with same props
+ await act(async () => {
+ rerender();
+ });
+
+ expect(queryByTestId('add-rewards-account')).toBeNull();
+ });
+ });
+});
diff --git a/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx
new file mode 100644
index 000000000000..45bc6e5bb0f9
--- /dev/null
+++ b/app/components/UI/Rewards/components/AddRewardsAccount/AddRewardsAccount.tsx
@@ -0,0 +1,85 @@
+import React, { useCallback, useMemo, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { InternalAccount } from '@metamask/keyring-internal-api';
+import {
+ Button,
+ ButtonSize,
+ ButtonVariant,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import { formatChainIdToCaip } from '@metamask/bridge-controller';
+import MetamaskRewardsPointsAlternativeImage from '../../../../../images/rewards/metamask-rewards-points-alternative.svg';
+import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts';
+import { selectSourceToken } from '../../../../../core/redux/slices/bridge';
+import { useLinkAccountAddress } from '../../hooks/useLinkAccountAddress';
+import { strings } from '../../../../../../locales/i18n';
+
+interface AddRewardsAccountProps {
+ account?: InternalAccount;
+ testID?: string;
+}
+
+const AddRewardsAccount: React.FC = ({
+ account,
+ testID = 'add-rewards-account',
+}) => {
+ const tw = useTailwind();
+ const sourceToken = useSelector(selectSourceToken);
+ const getSelectedAccountByScope = useSelector(
+ selectSelectedInternalAccountByScope,
+ );
+ const [isSuccess, setIsSuccess] = useState(false);
+ const accountScope = useMemo(() => {
+ if (account) {
+ return account;
+ }
+ const sourceChainId = sourceToken?.chainId
+ ? formatChainIdToCaip(sourceToken.chainId)
+ : undefined;
+ if (sourceChainId) {
+ return getSelectedAccountByScope(sourceChainId);
+ }
+ return undefined;
+ }, [account, sourceToken, getSelectedAccountByScope]);
+
+ const { linkAccountAddress, isLoading } = useLinkAccountAddress(true);
+
+ const handlePress = useCallback(async () => {
+ if (!accountScope) {
+ return;
+ }
+
+ const success = await linkAccountAddress(accountScope);
+ if (success) {
+ setIsSuccess(true);
+ }
+ }, [accountScope, linkAccountAddress]);
+
+ // Don't render if no account available or if successfully linked
+ if (!accountScope || isSuccess) {
+ return null;
+ }
+
+ return (
+
+ }
+ onPress={handlePress}
+ isDisabled={isLoading}
+ isLoading={isLoading}
+ testID={testID}
+ size={ButtonSize.Sm}
+ variant={ButtonVariant.Tertiary}
+ style={tw.style('p-0')}
+ >
+ {strings('rewards.link_account_group.link_account')}
+
+ );
+};
+
+export default AddRewardsAccount;
diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts
new file mode 100644
index 000000000000..cdb674e245a7
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.test.ts
@@ -0,0 +1,668 @@
+import { renderHook, act } from '@testing-library/react-hooks';
+import { InternalAccount } from '@metamask/keyring-internal-api';
+import { useLinkAccountAddress } from './useLinkAccountAddress';
+import Engine from '../../../../core/Engine';
+import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
+import { deriveAccountMetricProps } from '../utils';
+import useRewardsToast from './useRewardsToast';
+import { strings } from '../../../../../locales/i18n';
+import { formatAddress } from '../../../../util/address';
+import { IMetaMetricsEvent } from '../../../../core/Analytics';
+
+// Mock dependencies
+jest.mock('../../../../core/Engine', () => ({
+ controllerMessenger: {
+ call: jest.fn(),
+ },
+}));
+
+jest.mock('../../../hooks/useMetrics', () => ({
+ MetaMetricsEvents: {
+ REWARDS_ACCOUNT_LINKING_STARTED: 'Rewards Account Linking Started',
+ REWARDS_ACCOUNT_LINKING_COMPLETED: 'Rewards Account Linking Completed',
+ REWARDS_ACCOUNT_LINKING_FAILED: 'Rewards Account Linking Failed',
+ },
+ useMetrics: jest.fn(),
+}));
+
+jest.mock('../utils', () => ({
+ deriveAccountMetricProps: jest.fn(),
+}));
+
+jest.mock('./useRewardsToast', () => ({
+ __esModule: true,
+ default: jest.fn(),
+}));
+
+jest.mock('../../../../../locales/i18n', () => ({
+ strings: jest.fn((key: string, params?: Record) => {
+ if (key === 'rewards.link_account_group.link_account_address_error') {
+ return `Failed to link ${params?.address || 'account'}`;
+ }
+ return key;
+ }),
+}));
+
+jest.mock('../../../../util/address', () => ({
+ formatAddress: jest.fn(
+ (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`,
+ ),
+}));
+
+describe('useLinkAccountAddress', () => {
+ const mockEngineCall = Engine.controllerMessenger.call as jest.MockedFunction<
+ typeof Engine.controllerMessenger.call
+ >;
+ const mockUseMetrics = jest.mocked(useMetrics);
+ const mockDeriveAccountMetricProps = jest.mocked(deriveAccountMetricProps);
+ const mockUseRewardsToast = jest.mocked(useRewardsToast);
+ const mockStrings = jest.mocked(strings);
+ const mockFormatAddress = jest.mocked(formatAddress);
+
+ const mockTrackEvent = jest.fn();
+ const mockCreateEventBuilder = jest.fn().mockReturnValue({
+ addProperties: jest.fn().mockReturnThis(),
+ build: jest.fn().mockReturnValue({
+ event: expect.any(String),
+ properties: expect.any(Object),
+ } as unknown as IMetaMetricsEvent),
+ });
+
+ const mockShowToast = jest.fn();
+ const mockRewardsToastOptions = {
+ error: jest.fn().mockReturnValue({
+ variant: 'icon',
+ iconName: 'error',
+ hapticsType: 'error',
+ }),
+ };
+
+ const mockAccount: InternalAccount = {
+ id: 'test-account-id',
+ address: '0x1234567890123456789012345678901234567890',
+ type: 'eip155:eoa',
+ scopes: ['eip155:1'],
+ options: {},
+ methods: [],
+ metadata: {
+ name: 'Test Account',
+ importTime: Date.now(),
+ keyring: {
+ type: 'HD Key Tree',
+ },
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ // Setup useMetrics mock
+ mockUseMetrics.mockReturnValue({
+ trackEvent: mockTrackEvent,
+ createEventBuilder: mockCreateEventBuilder,
+ } as never);
+
+ // Setup useRewardsToast mock
+ mockUseRewardsToast.mockReturnValue({
+ showToast: mockShowToast,
+ RewardsToastOptions: {
+ ...mockRewardsToastOptions,
+ success: jest.fn().mockReturnValue({
+ variant: 'icon',
+ iconName: 'confirmation',
+ hapticsType: 'success',
+ }),
+ },
+ });
+
+ // Setup deriveAccountMetricProps mock
+ mockDeriveAccountMetricProps.mockReturnValue({
+ scope: 'evm',
+ account_type: 'HD Key Tree',
+ });
+
+ // Setup formatAddress mock
+ mockFormatAddress.mockImplementation(
+ (address: string) => `${address.slice(0, 6)}...${address.slice(-4)}`,
+ );
+ });
+
+ describe('Hook initialization', () => {
+ it('returns hook interface with initial state', () => {
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ expect(result.current).toEqual({
+ linkAccountAddress: expect.any(Function),
+ isLoading: false,
+ isError: false,
+ });
+ });
+
+ it('initializes with showToasts defaulting to true', () => {
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isError).toBe(false);
+ expect(typeof result.current.linkAccountAddress).toBe('function');
+ });
+
+ it('initializes with showToasts set to false when provided', () => {
+ const { result } = renderHook(() => useLinkAccountAddress(false));
+
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.isError).toBe(false);
+ });
+ });
+
+ describe('Successful account linking', () => {
+ it('links account when opt-in is supported and not already opted in', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus
+ .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(true);
+ expect(mockEngineCall).toHaveBeenCalledTimes(3);
+ expect(mockEngineCall).toHaveBeenNthCalledWith(
+ 1,
+ 'RewardsController:isOptInSupported',
+ mockAccount,
+ );
+ expect(mockEngineCall).toHaveBeenNthCalledWith(
+ 2,
+ 'RewardsController:getOptInStatus',
+ { addresses: [mockAccount.address] },
+ );
+ expect(mockEngineCall).toHaveBeenNthCalledWith(
+ 3,
+ 'RewardsController:linkAccountToSubscriptionCandidate',
+ mockAccount,
+ );
+ });
+
+ it('tracks started event when linking begins', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus
+ .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockDeriveAccountMetricProps).toHaveBeenCalledWith(mockAccount);
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_STARTED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalled();
+ });
+
+ it('tracks completed event when linking succeeds', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus
+ .mockResolvedValueOnce(true); // linkAccountToSubscriptionCandidate
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_COMPLETED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Completed
+ });
+
+ it('clears loading state in finally block', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(true);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ describe('Account already opted in', () => {
+ it('returns true immediately when account is already opted in', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [true] }); // getOptInStatus - already opted in
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(true);
+ expect(mockEngineCall).toHaveBeenCalledTimes(2);
+ expect(mockEngineCall).not.toHaveBeenCalledWith(
+ 'RewardsController:linkAccountToSubscriptionCandidate',
+ expect.anything(),
+ );
+ });
+
+ it('does not track events when account is already opted in', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [true] });
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockTrackEvent).not.toHaveBeenCalled();
+ });
+
+ it('does not show toast when account is already opted in', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [true] });
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Account not supported', () => {
+ it('returns false when account does not support opt-in', async () => {
+ mockEngineCall.mockResolvedValueOnce(false); // isOptInSupported
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(false);
+ expect(mockEngineCall).toHaveBeenCalledTimes(1);
+ expect(mockEngineCall).toHaveBeenCalledWith(
+ 'RewardsController:isOptInSupported',
+ mockAccount,
+ );
+ });
+
+ it('sets error state when account does not support opt-in', async () => {
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(result.current.isError).toBe(true);
+ });
+
+ it('shows error toast when account does not support opt-in and showToasts is true', async () => {
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockFormatAddress).toHaveBeenCalledWith(
+ mockAccount.address,
+ 'short',
+ );
+ expect(mockStrings).toHaveBeenCalledWith(
+ 'rewards.link_account_group.link_account_address_error',
+ {
+ address: expect.any(String),
+ },
+ );
+ expect(mockRewardsToastOptions.error).toHaveBeenCalled();
+ expect(mockShowToast).toHaveBeenCalled();
+ });
+
+ it('does not show toast when account does not support opt-in and showToasts is false', async () => {
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(false));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Linking failure', () => {
+ it('returns false when linkAccountToSubscriptionCandidate returns false', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus
+ .mockResolvedValueOnce(false); // linkAccountToSubscriptionCandidate - fails
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(false);
+ expect(result.current.isError).toBe(true);
+ });
+
+ it('tracks failed event when linkAccountToSubscriptionCandidate returns false', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Failed
+ });
+
+ it('shows error toast when linkAccountToSubscriptionCandidate returns false and showToasts is true', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockRewardsToastOptions.error).toHaveBeenCalled();
+ });
+
+ it('does not show toast when linkAccountToSubscriptionCandidate returns false and showToasts is false', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(false));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Error handling', () => {
+ it('handles error during isOptInSupported check', async () => {
+ const testError = new Error('Network error');
+ mockEngineCall.mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(false);
+ expect(result.current.isError).toBe(true);
+ expect(result.current.isLoading).toBe(false);
+ });
+
+ it('shows error toast when isOptInSupported throws and showToasts is true', async () => {
+ const testError = new Error('Network error');
+ mockEngineCall.mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockRewardsToastOptions.error).toHaveBeenCalled();
+ });
+
+ it('handles error during getOptInStatus check', async () => {
+ const testError = new Error('Status check failed');
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockRejectedValueOnce(testError); // getOptInStatus
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(false);
+ expect(result.current.isError).toBe(true);
+ });
+
+ it('handles error during linkAccountToSubscriptionCandidate', async () => {
+ const testError = new Error('Linking failed');
+ mockEngineCall
+ .mockResolvedValueOnce(true) // isOptInSupported
+ .mockResolvedValueOnce({ ois: [false] }) // getOptInStatus
+ .mockRejectedValueOnce(testError); // linkAccountToSubscriptionCandidate
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ let linkResult: boolean | undefined;
+ await act(async () => {
+ linkResult = await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(linkResult).toBe(false);
+ expect(result.current.isError).toBe(true);
+ });
+
+ it('tracks failed event when linkAccountToSubscriptionCandidate throws', async () => {
+ const testError = new Error('Linking failed');
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockCreateEventBuilder).toHaveBeenCalledWith(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED,
+ );
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2); // Started + Failed
+ });
+
+ it('shows error toast when linkAccountToSubscriptionCandidate throws and showToasts is true', async () => {
+ const testError = new Error('Linking failed');
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).toHaveBeenCalled();
+ expect(mockRewardsToastOptions.error).toHaveBeenCalled();
+ });
+
+ it('does not show toast when error occurs and showToasts is false', async () => {
+ const testError = new Error('Network error');
+ mockEngineCall.mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress(false));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('clears loading state even when error occurs', async () => {
+ const testError = new Error('Network error');
+ mockEngineCall.mockRejectedValueOnce(testError);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(result.current.isLoading).toBe(false);
+ });
+ });
+
+ describe('State management', () => {
+ it('resets error state when starting new link attempt', async () => {
+ // First attempt fails
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(result.current.isError).toBe(true);
+
+ // Second attempt succeeds
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(true);
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(result.current.isError).toBe(false);
+ });
+
+ it('maintains separate state for multiple hook instances', () => {
+ const { result: result1 } = renderHook(() => useLinkAccountAddress());
+ const { result: result2 } = renderHook(() => useLinkAccountAddress());
+
+ expect(result1.current.isLoading).toBe(false);
+ expect(result2.current.isLoading).toBe(false);
+ expect(result1.current.isError).toBe(false);
+ expect(result2.current.isError).toBe(false);
+ });
+ });
+
+ describe('Event tracking integration', () => {
+ it('calls deriveAccountMetricProps with correct account', async () => {
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(true);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockDeriveAccountMetricProps).toHaveBeenCalledWith(mockAccount);
+ });
+
+ it('builds event with account metric properties', async () => {
+ const mockAccountProps = {
+ scope: 'evm',
+ account_type: 'HD Key Tree',
+ };
+ mockDeriveAccountMetricProps.mockReturnValue(mockAccountProps);
+
+ mockEngineCall
+ .mockResolvedValueOnce(true)
+ .mockResolvedValueOnce({ ois: [false] })
+ .mockResolvedValueOnce(true);
+
+ const { result } = renderHook(() => useLinkAccountAddress());
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ const mockAddProperties = mockCreateEventBuilder().addProperties;
+ expect(mockAddProperties).toHaveBeenCalledWith(mockAccountProps);
+ });
+ });
+
+ describe('Toast integration', () => {
+ it('formats address correctly for toast message', async () => {
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockFormatAddress).toHaveBeenCalledWith(
+ mockAccount.address,
+ 'short',
+ );
+ });
+
+ it('uses formatted address in error message', async () => {
+ const formattedAddress = '0x1234...7890';
+ mockFormatAddress.mockReturnValue(formattedAddress);
+ mockEngineCall.mockResolvedValueOnce(false);
+
+ const { result } = renderHook(() => useLinkAccountAddress(true));
+
+ await act(async () => {
+ await result.current.linkAccountAddress(mockAccount);
+ });
+
+ expect(mockStrings).toHaveBeenCalledWith(
+ 'rewards.link_account_group.link_account_address_error',
+ {
+ address: formattedAddress,
+ },
+ );
+ });
+ });
+});
diff --git a/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts b/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts
new file mode 100644
index 000000000000..3b84a3cd4a9f
--- /dev/null
+++ b/app/components/UI/Rewards/hooks/useLinkAccountAddress.ts
@@ -0,0 +1,159 @@
+import { useCallback, useState } from 'react';
+import { InternalAccount } from '@metamask/keyring-internal-api';
+import Engine from '../../../../core/Engine';
+import { MetaMetricsEvents, useMetrics } from '../../../hooks/useMetrics';
+import { deriveAccountMetricProps } from '../utils';
+import { IMetaMetricsEvent } from '../../../../core/Analytics';
+import useRewardsToast from './useRewardsToast';
+import { strings } from '../../../../../locales/i18n';
+import { formatAddress } from '../../../../util/address';
+
+interface UseLinkAccountAddressResult {
+ linkAccountAddress: (account: InternalAccount) => Promise;
+ isLoading: boolean;
+ isError: boolean;
+}
+
+export const useLinkAccountAddress = (
+ showToasts: boolean = true,
+): UseLinkAccountAddressResult => {
+ const [isLoading, setIsLoading] = useState(false);
+ const [isError, setIsError] = useState(false);
+
+ const { trackEvent, createEventBuilder } = useMetrics();
+ const { showToast, RewardsToastOptions } = useRewardsToast();
+
+ const triggerAccountLinkingEvent = useCallback(
+ (event: IMetaMetricsEvent, account: InternalAccount) => {
+ const accountMetricProps = deriveAccountMetricProps(account);
+ trackEvent(
+ createEventBuilder(event).addProperties(accountMetricProps).build(),
+ );
+ },
+ [createEventBuilder, trackEvent],
+ );
+
+ const linkAccountAddress = useCallback(
+ async (account: InternalAccount): Promise => {
+ setIsLoading(true);
+ setIsError(false);
+
+ try {
+ // Check if account supports opt-in
+ const isSupported = await Engine.controllerMessenger.call(
+ 'RewardsController:isOptInSupported',
+ account,
+ );
+
+ if (!isSupported) {
+ setIsError(true);
+ if (showToasts) {
+ showToast(
+ RewardsToastOptions.error(
+ strings(
+ 'rewards.link_account_group.link_account_address_error',
+ {
+ address: formatAddress(account.address, 'short'),
+ },
+ ),
+ ),
+ );
+ }
+ return false;
+ }
+
+ // Check opt-in status
+ const optInResponse = await Engine.controllerMessenger.call(
+ 'RewardsController:getOptInStatus',
+ { addresses: [account.address] },
+ );
+
+ // If already opted in, return success
+ if (optInResponse.ois[0]) {
+ return true;
+ }
+
+ // Emit started event
+ triggerAccountLinkingEvent(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_STARTED,
+ account,
+ );
+
+ try {
+ // Link the account
+ const success = await Engine.controllerMessenger.call(
+ 'RewardsController:linkAccountToSubscriptionCandidate',
+ account,
+ );
+
+ if (success) {
+ triggerAccountLinkingEvent(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_COMPLETED,
+ account,
+ );
+ return true;
+ }
+
+ triggerAccountLinkingEvent(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED,
+ account,
+ );
+ if (showToasts) {
+ showToast(
+ RewardsToastOptions.error(
+ strings(
+ 'rewards.link_account_group.link_account_address_error',
+ {
+ address: formatAddress(account.address, 'short'),
+ },
+ ),
+ ),
+ );
+ }
+ setIsError(true);
+ return false;
+ } catch (err) {
+ triggerAccountLinkingEvent(
+ MetaMetricsEvents.REWARDS_ACCOUNT_LINKING_FAILED,
+ account,
+ );
+ if (showToasts) {
+ showToast(
+ RewardsToastOptions.error(
+ strings(
+ 'rewards.link_account_group.link_account_address_error',
+ {
+ address: formatAddress(account.address, 'short'),
+ },
+ ),
+ ),
+ );
+ }
+ setIsError(true);
+ return false;
+ }
+ } catch (err) {
+ if (showToasts) {
+ showToast(
+ RewardsToastOptions.error(
+ strings('rewards.link_account_group.link_account_address_error', {
+ address: formatAddress(account.address, 'short'),
+ }),
+ ),
+ );
+ }
+ setIsError(true);
+ return false;
+ } finally {
+ setIsLoading(false);
+ }
+ },
+ [showToasts, triggerAccountLinkingEvent, showToast, RewardsToastOptions],
+ );
+
+ return {
+ linkAccountAddress,
+ isLoading,
+ isError,
+ };
+};
diff --git a/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts b/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts
index da2daf946d31..5b0b63d5c22b 100644
--- a/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts
+++ b/app/components/UI/Rewards/hooks/useRewardsIntroModal.test.ts
@@ -3,10 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
import { useNavigation } from '@react-navigation/native';
import Routes from '../../../../constants/navigation/Routes';
import { useRewardsIntroModal } from './useRewardsIntroModal';
-import {
- selectRewardsEnabledFlag,
- selectRewardsAnnouncementModalEnabledFlag,
-} from '../../../../selectors/featureFlagController/rewards';
+import { selectRewardsAnnouncementModalEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { selectMultichainAccountsIntroModalSeen } from '../../../../reducers/user';
import { selectRewardsSubscriptionId } from '../../../../selectors/rewards';
import { setOnboardingActiveStep } from '../../../../reducers/rewards';
@@ -65,7 +62,6 @@ describe('useRewardsIntroModal', () => {
// Default selector values: all conditions satisfied
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true;
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -109,27 +105,8 @@ describe('useRewardsIntroModal', () => {
});
});
- it('does not navigate when rewards feature is disabled', async () => {
- mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return false;
- if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
- if (selector === selectMultichainAccountsIntroModalSeen) return true;
- if (selector === selectMultichainAccountsState2Enabled) return true;
- return undefined;
- });
- (StorageWrapper.getItem as jest.Mock).mockResolvedValueOnce('false');
-
- renderHook(() => useRewardsIntroModal());
-
- // Give effects a tick
- await waitFor(() => {
- expect(navigate).not.toHaveBeenCalled();
- });
- });
-
it('does not navigate when announcement flag is disabled', async () => {
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return false;
if (selector === selectMultichainAccountsIntroModalSeen) return true;
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -146,7 +123,6 @@ describe('useRewardsIntroModal', () => {
it('does not navigate when BIP44 intro modal has not been seen', async () => {
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return false;
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -163,7 +139,6 @@ describe('useRewardsIntroModal', () => {
it('does not navigate when subscriptionId is present', async () => {
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true;
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -184,7 +159,6 @@ describe('useRewardsIntroModal', () => {
it('sets storage flag when subscriptionId is present', async () => {
// Arrange
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true;
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -217,7 +191,6 @@ describe('useRewardsIntroModal', () => {
// Mock BIP-44 modal as already seen (from previous session)
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true; // Seen in previous session
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -247,7 +220,6 @@ describe('useRewardsIntroModal', () => {
// Start with BIP-44 modal already seen (from previous session)
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true; // Already seen
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -278,7 +250,6 @@ describe('useRewardsIntroModal', () => {
// Start with BIP-44 modal NOT seen initially
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return false; // Initially not seen
if (selector === selectMultichainAccountsState2Enabled) return true;
@@ -303,7 +274,6 @@ describe('useRewardsIntroModal', () => {
// Now simulate BIP-44 modal being seen (state changes from false to true)
mockUseSelector.mockImplementation((selector: unknown) => {
- if (selector === selectRewardsEnabledFlag) return true;
if (selector === selectRewardsAnnouncementModalEnabledFlag) return true;
if (selector === selectMultichainAccountsIntroModalSeen) return true; // Now seen
if (selector === selectMultichainAccountsState2Enabled) return true;
diff --git a/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts b/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts
index babe3a56dc9b..e168415c2373 100644
--- a/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts
+++ b/app/components/UI/Rewards/hooks/useRewardsIntroModal.ts
@@ -1,10 +1,7 @@
import { useNavigation } from '@react-navigation/native';
import { useCallback, useEffect, useState, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
-import {
- selectRewardsAnnouncementModalEnabledFlag,
- selectRewardsEnabledFlag,
-} from '../../../../selectors/featureFlagController/rewards';
+import { selectRewardsAnnouncementModalEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
import { selectMultichainAccountsIntroModalSeen } from '../../../../reducers/user';
import StorageWrapper from '../../../../store/storage-wrapper';
import {
@@ -24,17 +21,15 @@ const isE2ETest =
/**
* Hook to handle showing the rewards GTM intro modal
* Shows the modal only when:
- * 1. Rewards feature flag is enabled
- * 2. Rewards announcement feature flag is enabled
- * 3. The modal hasn't been seen before
- * 4. The MultichainAccountsIntroModal has been seen in a PREVIOUS session (not current)
- * 5. User does not have an active subscription
+ * 1. Rewards announcement feature flag is enabled
+ * 2. The modal hasn't been seen before
+ * 3. The MultichainAccountsIntroModal has been seen in a PREVIOUS session (not current)
+ * 4. User does not have an active subscription
*/
export const useRewardsIntroModal = () => {
const navigation = useNavigation();
const dispatch = useDispatch();
- const isRewardsFeatureEnabled = useSelector(selectRewardsEnabledFlag);
const isRewardsAnnouncementEnabled = useSelector(
selectRewardsAnnouncementModalEnabledFlag,
);
@@ -76,7 +71,6 @@ export const useRewardsIntroModal = () => {
const isUpdate = !!lastAppVersion && currentAppVersion !== lastAppVersion;
const shouldShow =
- isRewardsFeatureEnabled &&
isRewardsAnnouncementEnabled &&
// BIP44 intro modal has been seen in a PREVIOUS session (not current)
// OR it's a fresh install (which doesn't trigger bip44 modal)
@@ -95,7 +89,6 @@ export const useRewardsIntroModal = () => {
});
}
}, [
- isRewardsFeatureEnabled,
isMultichainAccountsState2Enabled,
isRewardsAnnouncementEnabled,
hasSeenBIP44IntroModal,
@@ -121,7 +114,6 @@ export const useRewardsIntroModal = () => {
}, [checkAndShowRewardsIntroModal]);
return {
- isRewardsFeatureEnabled,
hasSeenRewardsIntroModal,
};
};
diff --git a/app/components/UI/TransactionElement/utils.js b/app/components/UI/TransactionElement/utils.js
index 8260b46cd921..91079fb6f7b5 100644
--- a/app/components/UI/TransactionElement/utils.js
+++ b/app/components/UI/TransactionElement/utils.js
@@ -83,8 +83,7 @@ function getTokenTransfer(args) {
}
const isIncomplete = isTransactionIncomplete(status);
- const isSent =
- renderFullAddress(from)?.toLowerCase() === selectedAddress?.toLowerCase();
+ const isSent = from?.toLowerCase() === selectedAddress?.toLowerCase();
let actionVerb;
if (isSent) {
@@ -192,7 +191,7 @@ function getCollectibleTransfer(args) {
} = args;
const isIncomplete = isTransactionIncomplete(status);
- const isSent = renderFullAddress(from) === selectedAddress;
+ const isSent = from?.toLowerCase() === selectedAddress?.toLowerCase();
let actionVerb;
if (isSent) {
@@ -339,10 +338,7 @@ function decodeIncomingTransfer(args) {
: weiToFiatNumber(totalGas, conversionRate);
const { SENT_TOKEN, RECEIVED_TOKEN } = TRANSACTION_TYPES;
- const transactionType =
- renderFullAddress(from)?.toLowerCase() === selectedAddress?.toLowerCase()
- ? SENT_TOKEN
- : RECEIVED_TOKEN;
+ const transactionType = !isIncoming ? SENT_TOKEN : RECEIVED_TOKEN;
let transactionDetails = {
renderTotalGas: `${renderFromWei(totalGas)} ${ticker}`,
diff --git a/app/components/Views/Settings/index.tsx b/app/components/Views/Settings/index.tsx
index 6b8ce875973f..35ce6806c0d4 100644
--- a/app/components/Views/Settings/index.tsx
+++ b/app/components/Views/Settings/index.tsx
@@ -22,7 +22,6 @@ import { isTest } from '../../../util/test/utils';
import { isPermissionsSettingsV1Enabled } from '../../../util/networks';
import { selectIsEvmNetworkSelected } from '../../../selectors/multichainNetworkController';
import { selectSeedlessOnboardingLoginFlow } from '../../../selectors/seedlessOnboardingController';
-import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards';
const createStyles = (colors: Colors) =>
StyleSheet.create({
@@ -37,7 +36,6 @@ const Settings = () => {
const { colors } = useTheme();
const { trackEvent, createEventBuilder } = useMetrics();
const styles = createStyles(colors);
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navigation = useNavigation();
@@ -56,10 +54,9 @@ const Settings = () => {
strings('app_settings.title'),
colors,
navigation,
- isRewardsEnabled,
),
);
- }, [navigation, colors, isRewardsEnabled]);
+ }, [navigation, colors]);
useEffect(() => {
updateNavBar();
diff --git a/app/components/Views/SimpleWebview/index.tsx b/app/components/Views/SimpleWebview/index.tsx
index 691d29fd1c97..ba48ba91f9be 100644
--- a/app/components/Views/SimpleWebview/index.tsx
+++ b/app/components/Views/SimpleWebview/index.tsx
@@ -34,8 +34,11 @@ const SimpleWebView = () => {
useEffect(() => {
navigation.setOptions(getWebviewNavbar(navigation, route, colors));
- navigation && navigation.setParams({ dispatch: share });
- }, [navigation, route, share, colors]);
+ }, [navigation, route, colors]);
+
+ useEffect(() => {
+ navigation.setParams({ dispatch: share });
+ }, [navigation, share]);
return (
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
index 43a678480b64..fc683663db4a 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/ExploreSearchResults.tsx
@@ -116,10 +116,8 @@ const ExploreSearchResults: React.FC = ({
return section.renderSkeleton();
}
- // Get the onPress handler from the section config if it exists
// Cast navigation to 'never' to satisfy different navigation param list types
- const onPressHandler = section.getOnPressHandler?.(navigation as never);
- return section.renderItem(item.data as never, onPressHandler as never);
+ return section.renderRowItem(item.data, navigation);
},
[navigation, renderSectionHeader],
);
@@ -130,7 +128,7 @@ const ExploreSearchResults: React.FC = ({
return `skeleton-${item.sectionId}-${item.index}`;
const section = SECTIONS_CONFIG[item.sectionId];
- return section ? section.keyExtractor(item.data as never) : `item-${index}`;
+ return section ? section.keyExtractor(item.data) : `item-${index}`;
}, []);
if (flatData.length === 0) {
diff --git a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
index bdb2d5b3fedb..b63597a5ce39 100644
--- a/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
+++ b/app/components/Views/TrendingView/ExploreSearchScreen/components/ExploreSearchResults/config/useExploreSearch.ts
@@ -1,54 +1,15 @@
import { useState, useEffect, useMemo } from 'react';
import {
SECTIONS_ARRAY,
+ useSectionsData,
type SectionId,
- type SectionData,
} from '../../../../config/sections.config';
-import { usePerpsMarkets } from '../../../../../../UI/Perps/hooks/usePerpsMarkets';
-import { usePredictMarketData } from '../../../../../../UI/Predict/hooks/usePredictMarketData';
-import { useTrendingRequest } from '../../../../../../UI/Assets/hooks/useTrendingRequest';
export interface ExploreSearchResult {
data: Record;
isLoading: Record;
}
-/**
- * Internal hook to fetch data from all sections.
- * When adding a new section, add the hook call here.
- */
-const useExploreSearchData = (
- debouncedQuery: string,
-): Record => {
- const { results: trendingTokens, isLoading: isTokensLoading } =
- useTrendingRequest({});
-
- const { markets: perpsMarkets, isLoading: isPerpsLoading } =
- usePerpsMarkets();
-
- const { marketData: predictionMarkets, isFetching: isPredictionsLoading } =
- usePredictMarketData({
- category: 'trending',
- q: debouncedQuery || undefined,
- pageSize: debouncedQuery ? 20 : 3,
- });
-
- return {
- tokens: {
- data: trendingTokens,
- isLoading: isTokensLoading,
- },
- perps: {
- data: perpsMarkets,
- isLoading: isPerpsLoading,
- },
- predictions: {
- data: predictionMarkets,
- isLoading: isPredictionsLoading,
- },
- };
-};
-
/**
* GENERIC EXPLORE SEARCH HOOK
*
@@ -58,10 +19,6 @@ const useExploreSearchData = (
* - Filtering results based on section configurations
* - Returning top 3 items when no query is present
*
- * TO ADD A NEW SECTION:
- * 1. Add section configuration to sections.config.tsx
- * 2. Add hook call to useExploreSearchData above
- *
* @param query - Search query string
* @returns Search results grouped by section
*/
@@ -76,7 +33,8 @@ export const useExploreSearch = (query: string): ExploreSearchResult => {
return () => clearTimeout(timer);
}, [query]);
- const allSectionsData = useExploreSearchData(debouncedQuery);
+ // Fetch data for all sections using centralized hook
+ const allSectionsData = useSectionsData(debouncedQuery);
const filteredResults = useMemo(() => {
const isLoading: Record = {} as Record<
@@ -102,7 +60,7 @@ export const useExploreSearch = (query: string): ExploreSearchResult => {
} else {
// Filter items based on section's searchable text
data[section.id] = sectionData.data.filter((item) =>
- section.getSearchableText(item as never).includes(searchTerm),
+ section.getSearchableText(item).includes(searchTerm),
);
}
});
diff --git a/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx b/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx
deleted file mode 100644
index e060b08d2e95..000000000000
--- a/app/components/Views/TrendingView/PerpsSection/PerpsSection.test.tsx
+++ /dev/null
@@ -1,164 +0,0 @@
-import React from 'react';
-import { fireEvent } from '@testing-library/react-native';
-import renderWithProvider from '../../../../util/test/renderWithProvider';
-import { backgroundState } from '../../../../util/test/initial-root-state';
-import PerpsSection from './PerpsSection';
-import { usePerpsMarkets } from '../../../UI/Perps/hooks';
-import { PerpsMarketData } from '../../../UI/Perps/controllers/types';
-
-// Mock external dependencies and leaf components with deep dependencies
-jest.mock('../../../UI/Perps/hooks');
-jest.mock('../../../UI/Perps/components/PerpsMarketRowItem', () =>
- jest.fn(() => null),
-);
-jest.mock(
- '../../../UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton',
- () => {
- const { View } = jest.requireActual('react-native');
- return jest.fn(() => );
- },
-);
-jest.mock('@shopify/flash-list', () => {
- const { FlatList } = jest.requireActual('react-native');
- return {
- FlashList: FlatList,
- };
-});
-
-// Mock navigation
-const mockNavigate = jest.fn();
-
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => ({ navigate: mockNavigate }),
-}));
-
-const mockUsePerpsMarkets = jest.mocked(usePerpsMarkets);
-
-const initialState = {
- engine: {
- backgroundState,
- },
-};
-
-describe('PerpsSection', () => {
- const createMockMarket = (
- symbol: string,
- ): PerpsMarketData & { volumeNumber: number } => ({
- symbol,
- name: `${symbol} Token`,
- maxLeverage: '40x',
- price: '$50,000.00',
- change24h: '+$1,250.00',
- change24hPercent: '+2.5%',
- volume: '$1.2B',
- volumeNumber: 1200000000,
- openInterest: '$500M',
- fundingRate: 0.0001,
- marketType: 'crypto',
- });
-
- const mockMarkets: (PerpsMarketData & { volumeNumber: number })[] = [
- createMockMarket('BTC'),
- createMockMarket('ETH'),
- createMockMarket('SOL'),
- createMockMarket('AVAX'),
- createMockMarket('MATIC'),
- ];
-
- beforeEach(() => {
- jest.clearAllMocks();
- });
-
- it('renders skeleton loaders when data is loading', () => {
- mockUsePerpsMarkets.mockReturnValue({
- markets: [],
- isLoading: true,
- error: null,
- refresh: jest.fn(),
- isRefreshing: false,
- });
-
- const { getAllByTestId, queryByTestId } = renderWithProvider(
- ,
- {
- state: initialState,
- },
- );
-
- const skeletons = getAllByTestId('perps-skeleton');
- expect(skeletons).toHaveLength(3);
- expect(queryByTestId('perps-tokens-list')).toBeNull();
- });
-
- it('displays first 3 markets from hook data', () => {
- mockUsePerpsMarkets.mockReturnValue({
- markets: mockMarkets,
- isLoading: false,
- error: null,
- refresh: jest.fn(),
- isRefreshing: false,
- });
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const list = getByTestId('perps-tokens-list');
-
- expect(list.props.data).toHaveLength(3);
- expect(list.props.data[0].symbol).toBe('BTC');
- expect(list.props.data[1].symbol).toBe('ETH');
- expect(list.props.data[2].symbol).toBe('SOL');
- });
-
- it('navigates to market list when view all button is pressed', () => {
- mockUsePerpsMarkets.mockReturnValue({
- markets: mockMarkets,
- isLoading: false,
- error: null,
- refresh: jest.fn(),
- isRefreshing: false,
- });
-
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
-
- fireEvent.press(getByText('View all'));
-
- expect(mockNavigate).toHaveBeenCalledWith('Perps', {
- screen: 'PerpsTrendingView',
- params: {
- defaultMarketTypeFilter: 'all',
- },
- });
- });
-
- it('navigates to market details when market item is pressed', () => {
- mockUsePerpsMarkets.mockReturnValue({
- markets: mockMarkets,
- isLoading: false,
- error: null,
- refresh: jest.fn(),
- isRefreshing: false,
- });
-
- const { getByTestId } = renderWithProvider(, {
- state: initialState,
- });
-
- const list = getByTestId('perps-tokens-list');
- const renderItem = list.props.renderItem;
- const renderedItem = renderItem({ item: mockMarkets[0], index: 0 });
-
- renderedItem.props.onPress();
-
- expect(mockNavigate).toHaveBeenCalledWith('Perps', {
- screen: 'PerpsMarketDetails',
- params: {
- market: mockMarkets[0],
- },
- });
- });
-});
diff --git a/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx b/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx
deleted file mode 100644
index ed1ba6d91135..000000000000
--- a/app/components/Views/TrendingView/PerpsSection/PerpsSection.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import React, { useCallback } from 'react';
-import { View } from 'react-native';
-import SectionHeader from '../components/SectionHeader/SectionHeader';
-import SectionCard from '../components/SectionCard/SectionCard';
-import PerpsMarketRowSkeleton from '../../../UI/Perps/Views/PerpsMarketListView/components/PerpsMarketRowSkeleton';
-import { FlashList } from '@shopify/flash-list';
-import { usePerpsMarkets } from '../../../UI/Perps/hooks';
-import PerpsMarketRowItem from '../../../UI/Perps/components/PerpsMarketRowItem';
-import { PerpsMarketData } from '../../../UI/Perps/controllers/types';
-import { useNavigation } from '@react-navigation/native';
-import Routes from '../../../../constants/navigation/Routes';
-
-const PerpsSection = () => {
- const navigation = useNavigation();
- const { markets, isLoading } = usePerpsMarkets();
- const perpsTokens = markets.slice(0, 3);
-
- const handleTokenPress = useCallback(
- (market: PerpsMarketData) => {
- navigation.navigate(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: { market },
- });
- },
- [navigation],
- );
-
- return (
-
-
-
- {isLoading || perpsTokens.length === 0 ? (
- <>
-
-
-
- >
- ) : (
- (
- handleTokenPress(item)}
- />
- )}
- keyExtractor={(item) => item.symbol}
- keyboardShouldPersistTaps="handled"
- testID="perps-tokens-list"
- />
- )}
-
-
- );
-};
-
-export default PerpsSection;
diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts b/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts
deleted file mode 100644
index 3385572a1fd2..000000000000
--- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.styles.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-import { StyleSheet } from 'react-native';
-import { Theme } from '../../../../util/theme/models';
-
-interface PredictionSectionStylesVars {
- activeIndex: number;
- cardWidth: number;
-}
-
-const styleSheet = (params: {
- theme: Theme;
- vars: PredictionSectionStylesVars;
-}) => {
- const { theme } = params;
- const { colors } = theme;
-
- return StyleSheet.create({
- carouselItem: {
- width: params.vars.cardWidth * 0.8,
- borderRadius: 16,
- paddingHorizontal: 8,
- overflow: 'hidden',
- borderColor: colors.border.default,
- shadowColor: colors.shadow.default,
- },
- carouselItemLast: {
- width: params.vars.cardWidth,
- borderRadius: 16,
- paddingHorizontal: 8,
- overflow: 'hidden',
- borderColor: colors.border.default,
- shadowColor: colors.shadow.default,
- },
- carouselContentContainer: {
- paddingRight: 16,
- },
- paginationContainer: {
- marginTop: 16,
- gap: 8,
- },
- dot: {
- height: 8,
- width: 8,
- borderRadius: 4,
- backgroundColor: colors.border.muted,
- },
- dotActive: {
- height: 8,
- width: 24,
- borderRadius: 4,
- backgroundColor: colors.text.default,
- },
- });
-};
-
-export default styleSheet;
diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx
deleted file mode 100644
index 8d9a26d5dd46..000000000000
--- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.test.tsx
+++ /dev/null
@@ -1,254 +0,0 @@
-import React from 'react';
-import { fireEvent } from '@testing-library/react-native';
-import renderWithProvider from '../../../../util/test/renderWithProvider';
-import { backgroundState } from '../../../../util/test/initial-root-state';
-import PredictionSection from './PredictionSection';
-import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData';
-import Routes from '../../../../constants/navigation/Routes';
-import {
- PredictMarket as PredictMarketType,
- Recurrence,
-} from '../../../UI/Predict/types';
-
-// Mock navigation
-const mockNavigate = jest.fn();
-jest.mock('@react-navigation/native', () => ({
- ...jest.requireActual('@react-navigation/native'),
- useNavigation: () => ({
- navigate: mockNavigate,
- }),
-}));
-
-// Mock dependencies
-jest.mock('../../../UI/Predict/hooks/usePredictMarketData');
-jest.mock('../../../UI/Predict/components/PredictMarket', () => {
- const { View, Text } = jest.requireActual('react-native');
- return jest.fn(({ market, testID }) => (
-
- PredictMarket: {market.title}
-
- ));
-});
-jest.mock('../../../UI/Predict/components/PredictMarketSkeleton', () => {
- const { View, Text } = jest.requireActual('react-native');
- return jest.fn(({ testID }) => (
-
- Loading...
-
- ));
-});
-jest.mock('@shopify/flash-list', () => {
- const { FlatList } = jest.requireActual('react-native');
- return {
- FlashList: FlatList,
- };
-});
-
-const mockUsePredictMarketData = usePredictMarketData as jest.MockedFunction<
- typeof usePredictMarketData
->;
-
-const initialState = {
- engine: {
- backgroundState,
- },
-};
-
-describe('PredictionSection', () => {
- const createMockMarket = (id: string): PredictMarketType => ({
- id,
- providerId: 'test-provider',
- slug: `market-${id}`,
- title: `Market ${id}`,
- description: `Description for market ${id}`,
- image: `https://example.com/image-${id}.png`,
- status: 'open',
- recurrence: Recurrence.NONE,
- category: 'crypto',
- tags: [],
- outcomes: [],
- liquidity: 10000,
- volume: 50000,
- });
-
- const mockMarketData: PredictMarketType[] = [
- createMockMarket('1'),
- createMockMarket('2'),
- createMockMarket('3'),
- createMockMarket('4'),
- createMockMarket('5'),
- createMockMarket('6'),
- ];
-
- beforeEach(() => {
- jest.clearAllMocks();
- mockNavigate.mockClear();
- });
-
- afterEach(() => {
- jest.resetAllMocks();
- });
-
- describe('loading state', () => {
- it('renders skeleton loaders when fetching data', () => {
- mockUsePredictMarketData.mockReturnValue({
- marketData: [],
- isFetching: true,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
-
- const { getByText, getAllByTestId } = renderWithProvider(
- ,
- { state: initialState },
- );
-
- expect(getByText('Predictions')).toBeOnTheScreen();
- expect(getByText('View all')).toBeOnTheScreen();
- expect(
- getAllByTestId('prediction-carousel-skeleton').length,
- ).toBeGreaterThan(0);
- });
-
- it('renders header with view all button during loading', () => {
- mockUsePredictMarketData.mockReturnValue({
- marketData: [],
- isFetching: true,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
-
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
-
- expect(getByText('Predictions')).toBeOnTheScreen();
- expect(getByText('View all')).toBeOnTheScreen();
- });
-
- it('navigates to market list when view all is pressed during loading', () => {
- mockUsePredictMarketData.mockReturnValue({
- marketData: [],
- isFetching: true,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
-
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
-
- fireEvent.press(getByText('View all'));
-
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MARKET_LIST,
- });
- });
- });
-
- describe('empty state', () => {
- it('renders nothing when not fetching and data is empty', () => {
- mockUsePredictMarketData.mockReturnValue({
- marketData: [],
- isFetching: false,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
-
- const { toJSON } = renderWithProvider(, {
- state: initialState,
- });
-
- expect(toJSON()).toBeNull();
- });
- });
-
- describe('carousel with data', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- jest.resetAllMocks();
- mockUsePredictMarketData.mockReturnValue({
- marketData: mockMarketData,
- isFetching: false,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
- });
-
- it('renders section header with title and view all button', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
-
- expect(getByText('Predictions')).toBeOnTheScreen();
- expect(getByText('View all')).toBeOnTheScreen();
- });
- });
-
- describe('view all button', () => {
- beforeEach(() => {
- jest.clearAllMocks();
- jest.resetAllMocks();
- mockNavigate.mockClear();
- mockUsePredictMarketData.mockReturnValue({
- marketData: mockMarketData,
- isFetching: false,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
- });
-
- it('navigates to market list when view all button is pressed', () => {
- const { getByText } = renderWithProvider(, {
- state: initialState,
- });
-
- fireEvent.press(getByText('View all'));
-
- expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MARKET_LIST,
- });
- });
- });
-
- describe('data fetching', () => {
- it('calls usePredictMarketData with correct parameters', () => {
- mockUsePredictMarketData.mockReturnValue({
- marketData: mockMarketData,
- isFetching: false,
- isFetchingMore: false,
- error: null,
- hasMore: false,
- refetch: jest.fn(),
- fetchMore: jest.fn(),
- });
-
- renderWithProvider(, {
- state: initialState,
- });
-
- expect(mockUsePredictMarketData).toHaveBeenCalledWith({
- category: 'trending',
- pageSize: 6,
- });
- });
- });
-});
diff --git a/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx b/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx
deleted file mode 100644
index b034fefdcf03..000000000000
--- a/app/components/Views/TrendingView/PredictionSection/PredictionSection.tsx
+++ /dev/null
@@ -1,182 +0,0 @@
-import {
- Box,
- BoxFlexDirection,
- BoxAlignItems,
- BoxJustifyContent,
-} from '@metamask/design-system-react-native';
-import React, { useCallback, useRef, useState } from 'react';
-import {
- Dimensions,
- NativeScrollEvent,
- NativeSyntheticEvent,
- Pressable,
-} from 'react-native';
-import { FlashList, FlashListRef } from '@shopify/flash-list';
-import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData';
-import PredictMarket from '../../../UI/Predict/components/PredictMarket';
-import { PredictMarket as PredictMarketType } from '../../../UI/Predict/types';
-import { PredictEventValues } from '../../../UI/Predict/constants/eventNames';
-import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton';
-import { useStyles } from '../../../../component-library/hooks';
-import styleSheet from './PredictionSection.styles';
-import SectionHeader from '../components/SectionHeader/SectionHeader';
-
-const { width: SCREEN_WIDTH } = Dimensions.get('window');
-const CARD_WIDTH = SCREEN_WIDTH - 32; // 16px padding on each side
-const CARD_SPACING = 16;
-const ACTUAL_CARD_WIDTH = CARD_WIDTH * 0.8; // Actual rendered card width (80% to show peek of next card)
-const SNAP_INTERVAL = ACTUAL_CARD_WIDTH + CARD_SPACING;
-
-const PredictionSection = () => {
- const [activeIndex, setActiveIndex] = useState(0);
- const flashListRef = useRef>(null);
-
- const { styles } = useStyles(styleSheet, {
- activeIndex,
- cardWidth: CARD_WIDTH,
- });
-
- // Fetch prediction market data with limit of 6
- const { marketData, isFetching } = usePredictMarketData({
- category: 'trending',
- pageSize: 6,
- });
-
- const marketDataLength = marketData?.length ?? 0;
-
- const handleScroll = useCallback(
- (event: NativeSyntheticEvent) => {
- const scrollPosition = event.nativeEvent.contentOffset.x;
- const index = Math.round(scrollPosition / SNAP_INTERVAL);
- setActiveIndex(index);
- },
- [],
- );
-
- const scrollToIndex = useCallback((index: number) => {
- flashListRef.current?.scrollToIndex({
- index,
- animated: true,
- });
- setActiveIndex(index);
- }, []);
-
- const renderCarouselItem = useCallback(
- ({ item, index }: { item: PredictMarketType; index: number }) => {
- const isLast = index === marketDataLength - 1;
-
- return (
-
-
-
- );
- },
- [styles, marketDataLength],
- );
-
- const renderPaginationDots = useCallback(
- () => (
-
- {Array.from({ length: marketDataLength }).map((_, index) => {
- const isActive = activeIndex === index;
- return (
- scrollToIndex(index)}
- hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
- >
-
-
- );
- })}
-
- ),
- [marketDataLength, activeIndex, scrollToIndex, styles],
- );
-
- // Show loading state while fetching
- if (isFetching) {
- return (
-
-
-
- {
- const isLast = index === 2; // 3 items (0, 1, 2)
-
- return (
-
-
-
- );
- }}
- keyExtractor={(item) => `skeleton-${item}`}
- contentContainerStyle={styles.carouselContentContainer}
- />
-
-
-
- {[0, 1, 2].map((index) => (
-
- ))}
-
-
-
- );
- }
-
- // Show empty state when no data
- if (marketDataLength === 0) {
- return null; // Don't show the section if there are no predictions
- }
-
- return (
-
-
-
-
- item.id}
- horizontal
- pagingEnabled={false}
- showsHorizontalScrollIndicator={false}
- snapToInterval={SNAP_INTERVAL}
- decelerationRate="fast"
- onScroll={handleScroll}
- scrollEventThrottle={16}
- contentContainerStyle={styles.carouselContentContainer}
- />
-
-
- {renderPaginationDots()}
-
- );
-};
-
-export default PredictionSection;
diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx
index e5b068690082..3dcdd345e698 100644
--- a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx
+++ b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensList/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx
@@ -325,7 +325,7 @@ describe('TrendingTokenRowItem', () => {
rpcPrefs: {
imageSource: 'https://popular-network.png',
},
- } as never);
+ });
mockGetDefaultNetworkByChainId.mockReturnValue(undefined);
const token = createMockToken();
@@ -352,7 +352,7 @@ describe('TrendingTokenRowItem', () => {
rpcPrefs: {
imageSource: 'https://unpopular-network.png',
},
- } as never);
+ });
mockGetDefaultNetworkByChainId.mockReturnValue(undefined);
const token = createMockToken();
diff --git a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx b/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx
deleted file mode 100644
index e5a679112226..000000000000
--- a/app/components/Views/TrendingView/TrendingTokensSection/TrendingTokensSection.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import React, { useCallback } from 'react';
-import { View } from 'react-native';
-import { TrendingAsset } from '@metamask/assets-controllers';
-import TrendingTokensSkeleton from './TrendingTokenSkeleton/TrendingTokensSkeleton';
-import TrendingTokensList from './TrendingTokensList';
-import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest';
-import SectionHeader from '../components/SectionHeader/SectionHeader';
-import SectionCard from '../components/SectionCard/SectionCard';
-
-const TrendingTokensSection = () => {
- const { results: trendingTokensResults, isLoading } = useTrendingRequest({});
- const trendingTokens = trendingTokensResults.slice(0, 3);
-
- const handleTokenPress = useCallback((token: TrendingAsset) => {
- // eslint-disable-next-line no-console
- console.log('🚀 ~ TrendingTokensSection ~ token:', token);
- // TODO: Implement token press logic
- }, []);
-
- return (
-
-
-
- {isLoading || trendingTokens.length === 0 ? (
-
- ) : (
-
- )}
-
-
- );
-};
-
-export default TrendingTokensSection;
diff --git a/app/components/Views/TrendingView/TrendingView.tsx b/app/components/Views/TrendingView/TrendingView.tsx
index ee7e25446564..93d69fecae7b 100644
--- a/app/components/Views/TrendingView/TrendingView.tsx
+++ b/app/components/Views/TrendingView/TrendingView.tsx
@@ -23,15 +23,18 @@ import {
lastTrendingScreenRef,
updateLastTrendingScreen,
} from '../../Nav/Main/MainNavigator';
-import TrendingTokensSection from './TrendingTokensSection/TrendingTokensSection';
-import { PerpsStreamProvider } from '../../UI/Perps/providers/PerpsStreamManager';
import ExploreSearchScreen from './ExploreSearchScreen/ExploreSearchScreen';
import ExploreSearchBar from './ExploreSearchBar/ExploreSearchBar';
-import { PredictModalStack } from '../../UI/Predict/routes';
-import PredictionSection from './PredictionSection/PredictionSection';
-import PerpsSection from './PerpsSection/PerpsSection';
-import { PerpsConnectionProvider } from '../../UI/Perps/providers/PerpsConnectionProvider';
+import {
+ PredictScreenStack,
+ PredictModalStack,
+ PredictMarketDetails,
+ PredictSellPreview,
+} from '../../UI/Predict';
+import PredictBuyPreview from '../../UI/Predict/views/PredictBuyPreview/PredictBuyPreview';
import QuickActions from './components/QuickActions/QuickActions';
+import SectionHeader from './components/SectionHeader/SectionHeader';
+import { HOME_SECTIONS_ARRAY } from './config/sections.config';
const Stack = createStackNavigator();
@@ -129,13 +132,13 @@ const TrendingFeed: React.FC = () => {
showsVerticalScrollIndicator={false}
>
-
-
-
-
-
-
-
+
+ {HOME_SECTIONS_ARRAY.map((section) => (
+
+
+ {section.renderSection()}
+
+ ))}
);
@@ -157,6 +160,17 @@ const TrendingView: React.FC = () => {
name={Routes.EXPLORE_SEARCH}
component={ExploreSearchScreen}
/>
+
{
animationEnabled: false,
}}
/>
+
+
+
);
};
diff --git a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx
index c44b4403c1cf..64b1d4cd7484 100644
--- a/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx
+++ b/app/components/Views/TrendingView/components/QuickActions/QuickActions.tsx
@@ -21,7 +21,7 @@ const QuickActions: React.FC = () => {
section.navigationAction(navigation)}
+ onPress={() => section.viewAllAction(navigation)}
testID={`quick-action-${section.id}`}
textProps={{ variant: TextVariant.BodySm }}
>
diff --git a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
index 64a67852fb7b..c63f5f62a0f5 100644
--- a/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
+++ b/app/components/Views/TrendingView/components/SectionCard/SectionCard.tsx
@@ -1,8 +1,11 @@
-import React, { PropsWithChildren, useMemo } from 'react';
+import React, { useCallback, useMemo } from 'react';
import { StyleSheet } from 'react-native';
import { Theme } from '../../../../../util/theme/models';
import { useAppThemeFromContext } from '../../../../../util/theme';
import Card from '../../../../../component-library/components/Cards/Card';
+import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config';
+import { FlashList, ListRenderItem } from '@shopify/flash-list';
+import { useNavigation } from '@react-navigation/native';
const createStyles = (theme: Theme) =>
StyleSheet.create({
@@ -12,17 +15,46 @@ const createStyles = (theme: Theme) =>
paddingVertical: 16,
paddingHorizontal: 16,
backgroundColor: theme.colors.background.muted,
- borderColor: theme.colors.border.muted,
+ borderWidth: 0,
},
});
+interface SectionCardProps {
+ sectionId: SectionId;
+}
-const SectionCard: React.FC = ({ children }) => {
+const SectionCard: React.FC = ({ sectionId }) => {
+ const navigation = useNavigation();
const theme = useAppThemeFromContext();
const styles = useMemo(() => createStyles(theme), [theme]);
+ const { data, isLoading } = SECTIONS_CONFIG[sectionId].useSectionData();
+
+ const renderFlatItem: ListRenderItem = useCallback(
+ ({ item }) => {
+ const section = SECTIONS_CONFIG[sectionId];
+ return section.renderRowItem(item, navigation);
+ },
+ [navigation, sectionId],
+ );
+
return (
- {children}
+ {isLoading && (
+ <>
+ {SECTIONS_CONFIG[sectionId].renderSkeleton()}
+ {SECTIONS_CONFIG[sectionId].renderSkeleton()}
+ {SECTIONS_CONFIG[sectionId].renderSkeleton()}
+ >
+ )}
+ {!isLoading && (
+ SECTIONS_CONFIG[sectionId].keyExtractor(item)}
+ keyboardShouldPersistTaps="handled"
+ testID="perps-tokens-list"
+ />
+ )}
);
};
diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx
new file mode 100644
index 000000000000..0aafba1ccedb
--- /dev/null
+++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.test.tsx
@@ -0,0 +1,95 @@
+import React from 'react';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import SectionCarrousel from './SectionCarrousel';
+import type { PredictMarket } from '../../../../UI/Predict/types';
+
+// Mock navigation
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: jest.fn(() => ({
+ navigate: jest.fn(),
+ })),
+ };
+});
+
+// Mock Predict components
+jest.mock(
+ '../../../../UI/Predict/components/PredictMarket',
+ () => 'PredictMarket',
+);
+jest.mock(
+ '../../../../UI/Predict/components/PredictMarketSkeleton',
+ () => 'PredictMarketSkeleton',
+);
+
+// Mock Predict data hook
+const mockUsePredictMarketData = jest.fn();
+jest.mock('../../../../UI/Predict/hooks/usePredictMarketData', () => ({
+ usePredictMarketData: () => mockUsePredictMarketData(),
+}));
+
+const initialState = {
+ engine: {
+ backgroundState,
+ },
+};
+
+const createMockPredictMarket = (id: string, title: string): PredictMarket =>
+ ({
+ id,
+ title,
+ outcomes: [],
+ status: 'active',
+ }) as unknown as PredictMarket;
+
+describe('SectionCarrousel', () => {
+ const mockData: PredictMarket[] = [
+ createMockPredictMarket('1', 'Market 1'),
+ createMockPredictMarket('2', 'Market 2'),
+ createMockPredictMarket('3', 'Market 3'),
+ ];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ mockUsePredictMarketData.mockReturnValue({
+ marketData: mockData,
+ isFetching: false,
+ });
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders carousel with data items and pagination dots', () => {
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen();
+ });
+
+ it('renders skeleton items with pagination when loading', () => {
+ mockUsePredictMarketData.mockReturnValue({
+ marketData: [],
+ isFetching: true,
+ });
+
+ const { getByTestId } = renderWithProvider(
+ ,
+ { state: initialState },
+ );
+
+ expect(getByTestId('predictions-flash-list')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-0')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-1')).toBeOnTheScreen();
+ expect(getByTestId('predictions-pagination-dot-2')).toBeOnTheScreen();
+ });
+});
diff --git a/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx
new file mode 100644
index 000000000000..56c5a5d07cc4
--- /dev/null
+++ b/app/components/Views/TrendingView/components/SectionCarrousel/SectionCarrousel.tsx
@@ -0,0 +1,137 @@
+import {
+ Box,
+ BoxFlexDirection,
+ BoxAlignItems,
+ BoxJustifyContent,
+} from '@metamask/design-system-react-native';
+import { useTailwind } from '@metamask/design-system-twrnc-preset';
+import React, { useCallback, useRef, useState } from 'react';
+import {
+ Dimensions,
+ NativeScrollEvent,
+ NativeSyntheticEvent,
+ Pressable,
+} from 'react-native';
+import { FlashList, FlashListRef } from '@shopify/flash-list';
+import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config';
+import { useNavigation } from '@react-navigation/native';
+
+const { width: SCREEN_WIDTH } = Dimensions.get('window');
+const CARD_WIDTH = SCREEN_WIDTH - 32; // 16px padding on each side
+const CARD_SPACING = 16;
+const ACTUAL_CARD_WIDTH = CARD_WIDTH * 0.8; // Actual rendered card width (80% to show peek of next card)
+const SNAP_INTERVAL = ACTUAL_CARD_WIDTH + CARD_SPACING;
+
+export interface SectionCarrouselProps {
+ sectionId: SectionId;
+}
+
+const SectionCarrousel: React.FC = ({ sectionId }) => {
+ const tw = useTailwind();
+ const navigation = useNavigation();
+ const [activeIndex, setActiveIndex] = useState(0);
+ const flashListRef = useRef>(null);
+
+ const section = SECTIONS_CONFIG[sectionId];
+ const { data, isLoading } = section.useSectionData();
+
+ const skeletonCount = 3;
+ const skeletonData = Array.from({ length: skeletonCount });
+
+ const displayData = isLoading ? skeletonData : data;
+ const displayDataLength = displayData.length;
+
+ const handleScroll = useCallback(
+ (event: NativeSyntheticEvent) => {
+ const scrollPosition = event.nativeEvent.contentOffset.x;
+ const index = Math.round(scrollPosition / SNAP_INTERVAL);
+ setActiveIndex(index);
+ },
+ [],
+ );
+
+ const scrollToIndex = useCallback((index: number) => {
+ flashListRef.current?.scrollToIndex({
+ index,
+ animated: true,
+ });
+ setActiveIndex(index);
+ }, []);
+
+ const renderItem = useCallback(
+ ({ item, index }: { item: unknown; index: number }) => {
+ const isLast = index === displayDataLength - 1;
+ const cardWidthStyle = { width: isLast ? CARD_WIDTH : CARD_WIDTH * 0.8 };
+
+ return (
+
+ {isLoading
+ ? section.renderSkeleton()
+ : section.renderRowItem(item, navigation)}
+
+ );
+ },
+ [displayDataLength, isLoading, section, navigation],
+ );
+
+ const renderPaginationDots = useCallback(
+ () => (
+
+ {Array.from({ length: displayDataLength }).map((_, index) => {
+ const isActive = activeIndex === index;
+ return (
+ scrollToIndex(index)}
+ hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
+ testID={`${sectionId}-pagination-dot-${index}`}
+ >
+
+
+ );
+ })}
+
+ ),
+ [displayDataLength, activeIndex, scrollToIndex, sectionId],
+ );
+
+ return (
+
+
+ isLoading ? `skeleton-${index}` : section.keyExtractor(item)
+ }
+ horizontal
+ pagingEnabled={false}
+ showsHorizontalScrollIndicator={false}
+ snapToInterval={SNAP_INTERVAL}
+ decelerationRate="fast"
+ onScroll={handleScroll}
+ scrollEventThrottle={16}
+ contentContainerStyle={tw.style('pr-4')}
+ testID={`${sectionId}-flash-list`}
+ />
+
+ {renderPaginationDots()}
+
+ );
+};
+
+export default SectionCarrousel;
diff --git a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
index 823dc8da2069..9431c91ea7b0 100644
--- a/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
+++ b/app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx
@@ -46,9 +46,7 @@ const SectionHeader: React.FC = ({ sectionId }) => {
{sectionConfig.title}
- sectionConfig.navigationAction(navigation)}
- >
+ sectionConfig.viewAllAction(navigation)}>
{strings('trending.view_all')}
diff --git a/app/components/Views/TrendingView/config/sections.config.tsx b/app/components/Views/TrendingView/config/sections.config.tsx
index fd92ebc2689d..166d33c19c48 100644
--- a/app/components/Views/TrendingView/config/sections.config.tsx
+++ b/app/components/Views/TrendingView/config/sections.config.tsx
@@ -12,111 +12,195 @@ import PredictMarket from '../../../UI/Predict/components/PredictMarket';
import type { PredictMarket as PredictMarketType } from '../../../UI/Predict/types';
import type { PerpsNavigationParamList } from '../../../UI/Perps/types/navigation';
import PredictMarketSkeleton from '../../../UI/Predict/components/PredictMarketSkeleton';
+import SectionCard from '../components/SectionCard/SectionCard';
+import SectionCarrousel from '../components/SectionCarrousel/SectionCarrousel';
+import { useTrendingRequest } from '../../../UI/Assets/hooks/useTrendingRequest';
+import { usePredictMarketData } from '../../../UI/Predict/hooks/usePredictMarketData';
+import { usePerpsMarkets } from '../../../UI/Perps/hooks';
+import { PerpsConnectionProvider } from '../../../UI/Perps/providers/PerpsConnectionProvider';
+import { PerpsStreamProvider } from '../../../UI/Perps/providers/PerpsStreamManager';
export type SectionId = 'predictions' | 'tokens' | 'perps';
-export interface SectionData {
+interface SectionData {
data: unknown[];
isLoading: boolean;
}
-/**
- * Configuration for each section in the Trending View.
- * This includes navigation, display, and search functionality.
- */
-export interface SectionConfig {
+interface SectionConfig {
+ id: SectionId;
title: string;
- navigationAction: (navigation: NavigationProp) => void;
- renderItem: (item: unknown, onPress?: (item: unknown) => void) => JSX.Element;
+ viewAllAction: (navigation: NavigationProp) => void;
+ renderRowItem: (
+ item: unknown,
+ navigation: NavigationProp,
+ ) => JSX.Element;
renderSkeleton: () => JSX.Element;
getSearchableText: (item: unknown) => string;
keyExtractor: (item: unknown) => string;
- getOnPressHandler?: (
- navigation: NavigationProp,
- ) => (item: unknown) => void;
+ renderSection: () => JSX.Element;
+ useSectionData: (searchQuery?: string) => {
+ data: unknown[];
+ isLoading: boolean;
+ };
}
-const tokensConfig: SectionConfig = {
- title: strings('trending.tokens'),
- navigationAction: (_navigation) => {
- // TODO: Implement tokens navigation when ready
- // _navigation.navigate(...);
- },
- renderItem: (item) => (
- undefined}
- />
- ),
- renderSkeleton: () => ,
- getSearchableText: (item) =>
- `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(),
- keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`,
-};
+/**
+ * Centralized configuration for all Trending View sections.
+ * This config is used by QuickActions, SectionHeaders, Search, and TrendingView rendering.
+ *
+ * To add a new section (EVERYTHING IN THIS FILE):
+ * 1. Add the section ID to the SectionId type above
+ * 2. Add the config to SECTIONS_CONFIG, HOME_SECTIONS_ARRAY, and SECTIONS_ARRAY below
+ * 3. Add the hook to useSectionsData below
+ *
+ * The section will automatically appear in:
+ * - TrendingView main feed
+ * - QuickActions buttons
+ * - Search results
+ * - Section headers with "View All" navigation
+ */
+
+export const SECTIONS_CONFIG: Record = {
+ tokens: {
+ id: 'tokens',
+ title: strings('trending.tokens'),
+ viewAllAction: (_navigation) => {
+ // TODO: Implement tokens navigation when ready
+ // _navigation.navigate(...);
+ },
+ renderRowItem: (item) => (
+ undefined}
+ />
+ ),
+ renderSkeleton: () => ,
+ getSearchableText: (item) =>
+ `${(item as TrendingAsset).symbol} ${(item as TrendingAsset).name}`.toLowerCase(),
+ keyExtractor: (item) => `token-${(item as TrendingAsset).assetId}`,
+ renderSection: () => ,
+ useSectionData: () => {
+ const { results, isLoading } = useTrendingRequest({});
-const perpsConfig: SectionConfig = {
- title: strings('trending.perps'),
- navigationAction: (navigation) => {
- navigation.navigate(Routes.PERPS.ROOT, {
- screen: Routes.PERPS.MARKET_LIST,
- params: {
- defaultMarketTypeFilter: 'all',
- },
- });
+ return { data: results, isLoading };
+ },
},
- renderItem: (item, onPress) => (
- onPress?.(item)}
- showBadge={false}
- />
- ),
- renderSkeleton: () => ,
- getSearchableText: (item) =>
- `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(),
- keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`,
- getOnPressHandler: (navigation) => (market) => {
- (navigation as NavigationProp).navigate(
- Routes.PERPS.ROOT,
- {
- screen: Routes.PERPS.MARKET_DETAILS,
- params: { market: market as PerpsMarketData },
- },
- );
+ perps: {
+ id: 'perps',
+ title: strings('trending.perps'),
+ viewAllAction: (navigation) => {
+ navigation.navigate(Routes.PERPS.ROOT, {
+ screen: Routes.PERPS.MARKET_LIST,
+ params: {
+ defaultMarketTypeFilter: 'all',
+ },
+ });
+ },
+ renderRowItem: (item, navigation) => (
+ {
+ (navigation as NavigationProp)?.navigate(
+ Routes.PERPS.ROOT,
+ {
+ screen: Routes.PERPS.MARKET_DETAILS,
+ params: { market: item as PerpsMarketData },
+ },
+ );
+ }}
+ showBadge={false}
+ />
+ ),
+ renderSkeleton: () => ,
+ getSearchableText: (item) =>
+ `${(item as PerpsMarketData).symbol} ${(item as PerpsMarketData).name || ''}`.toLowerCase(),
+ keyExtractor: (item) => `perp-${(item as PerpsMarketData).symbol}`,
+ renderSection: () => (
+
+
+
+
+
+ ),
+ useSectionData: () => {
+ const { markets, isLoading } = usePerpsMarkets();
+
+ return { data: markets, isLoading };
+ },
},
-};
+ predictions: {
+ id: 'predictions',
+ title: strings('wallet.predict'),
+ viewAllAction: (navigation) => {
+ navigation.navigate(Routes.PREDICT.ROOT, {
+ screen: Routes.PREDICT.MARKET_LIST,
+ });
+ },
+ renderRowItem: (item) => (
+
+ ),
+ renderSkeleton: () => ,
+ getSearchableText: (item) =>
+ (item as PredictMarketType).title.toLowerCase(),
+ keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`,
+ renderSection: () => ,
+ useSectionData: (searchQuery?: string) => {
+ const { marketData, isFetching } = usePredictMarketData({
+ category: 'trending',
+ pageSize: searchQuery ? 20 : 6,
+ q: searchQuery || undefined,
+ });
-const predictionsConfig: SectionConfig = {
- title: strings('wallet.predict'),
- navigationAction: (navigation) => {
- navigation.navigate(Routes.PREDICT.ROOT, {
- screen: Routes.PREDICT.MARKET_LIST,
- });
+ return { data: marketData, isLoading: isFetching };
+ },
},
- renderItem: (item) => ,
- renderSkeleton: () => ,
- getSearchableText: (item) => (item as PredictMarketType).title.toLowerCase(),
- keyExtractor: (item) => `prediction-${(item as PredictMarketType).id}`,
};
+// Sorted by order on the main screen
+export const HOME_SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [
+ SECTIONS_CONFIG.predictions,
+ SECTIONS_CONFIG.tokens,
+ SECTIONS_CONFIG.perps,
+];
+
+// Sorted by order on the QuickAction buttons and SearchResults
+export const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [
+ SECTIONS_CONFIG.tokens,
+ SECTIONS_CONFIG.perps,
+ SECTIONS_CONFIG.predictions,
+];
+
/**
- * Centralized configuration for all Trending View sections.
- * This config is used by QuickActions, SectionHeaders, and Search functionality.
+ * Centralized hook that fetches data for all sections.
+ * When adding a new section, add its hook call here.
+ * This keeps all section-related logic in one file.
*
- * To add a new section:
- * 1. Add the section ID to the SectionId type
- * 2. Create a config constant above (e.g., newSectionConfig)
- * 3. Add it to both SECTIONS_CONFIG and SECTIONS_ARRAY below
- * 4. Add data fetching in useExploreSearchData hook
+ * @param searchQuery - Optional search query for sections that support search
+ * @returns Data and loading state for all sections
*/
-export const SECTIONS_CONFIG: Record = {
- tokens: tokensConfig,
- perps: perpsConfig,
- predictions: predictionsConfig,
-};
+export const useSectionsData = (
+ searchQuery?: string,
+): Record => {
+ const { data: trendingTokens, isLoading: isTokensLoading } =
+ SECTIONS_CONFIG.tokens.useSectionData();
+ const { data: perpsMarkets, isLoading: isPerpsLoading } =
+ SECTIONS_CONFIG.perps.useSectionData();
+ const { data: predictionMarkets, isLoading: isPredictionsLoading } =
+ SECTIONS_CONFIG.predictions.useSectionData(searchQuery);
-export const SECTIONS_ARRAY: (SectionConfig & { id: SectionId })[] = [
- { id: 'tokens', ...tokensConfig },
- { id: 'perps', ...perpsConfig },
- { id: 'predictions', ...predictionsConfig },
-];
+ return {
+ tokens: {
+ data: trendingTokens,
+ isLoading: isTokensLoading,
+ },
+ perps: {
+ data: perpsMarkets,
+ isLoading: isPerpsLoading,
+ },
+ predictions: {
+ data: predictionMarkets,
+ isLoading: isPredictionsLoading,
+ },
+ };
+};
diff --git a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap
index 43df814c857d..610c7cafec79 100644
--- a/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap
+++ b/app/components/Views/Wallet/__snapshots__/index.test.tsx.snap
@@ -448,6 +448,93 @@ exports[`Wallet Conditional Rendering should render banner when basic functional
}
/>
+
+
+
+
+
@@ -1537,6 +1624,93 @@ exports[`Wallet Conditional Rendering should render loader when no selected acco
}
/>
+
+
+
+
+
@@ -2626,6 +2800,93 @@ exports[`Wallet should render correctly 1`] = `
}
/>
+
+
+
+
+
@@ -3715,6 +3976,93 @@ exports[`Wallet should render correctly when Solana support is enabled 1`] = `
}
/>
+
+
+
+
+
@@ -4804,6 +5152,93 @@ exports[`Wallet should render correctly when there are no detected tokens 1`] =
}
/>
+
+
+
+
+
diff --git a/app/components/Views/Wallet/index.tsx b/app/components/Views/Wallet/index.tsx
index e4e3fb1d7f8c..f05216dd4cd2 100644
--- a/app/components/Views/Wallet/index.tsx
+++ b/app/components/Views/Wallet/index.tsx
@@ -183,7 +183,6 @@ import PredictTabView from '../../UI/Predict/views/PredictTabView';
import { InitSendLocation } from '../confirmations/constants/send';
import { useSendNavigation } from '../confirmations/hooks/useSendNavigation';
import { selectCarouselBannersFlag } from '../../UI/Carousel/selectors/featureFlags';
-import { selectRewardsEnabledFlag } from '../../../selectors/featureFlagController/rewards';
import { SolScope } from '@metamask/keyring-api';
import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts';
import { EVM_SCOPE } from '../../UI/Earn/constants/networks';
@@ -1091,7 +1090,6 @@ const Wallet = ({
);
const shouldDisplayCardButton = useSelector(selectDisplayCardButton);
- const isRewardsEnabled = useSelector(selectRewardsEnabledFlag);
const isHomepageRedesignV1Enabled = useSelector(
selectHomepageRedesignV1Enabled,
);
@@ -1113,7 +1111,6 @@ const Wallet = ({
unreadNotificationCount,
readNotificationCount,
shouldDisplayCardButton,
- isRewardsEnabled,
),
);
}, [
@@ -1129,7 +1126,6 @@ const Wallet = ({
unreadNotificationCount,
readNotificationCount,
shouldDisplayCardButton,
- isRewardsEnabled,
]);
const getTokenAddedAnalyticsParams = useCallback(
diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx
index 9dba197333bd..2b8e9a855086 100755
--- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx
+++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx
@@ -31,6 +31,14 @@ import { useTransactionMetadataRequest } from '../../hooks/transactions/useTrans
import { hasTransactionType } from '../../utils/transaction';
import { PredictClaimInfoSkeleton } from '../info/predict-claim-info';
+const TRANSACTION_TYPES_DISABLE_SCROLL = [TransactionType.predictClaim];
+
+const TRANSACTION_TYPES_DISABLE_ALERT_BANNER = [
+ TransactionType.perpsDeposit,
+ TransactionType.predictDeposit,
+ TransactionType.predictWithdraw,
+];
+
export enum ConfirmationLoader {
Default = 'default',
CustomAmount = 'customAmount',
@@ -67,7 +75,9 @@ const ConfirmWrapped = ({
>
<>
-
+
>
@@ -210,5 +220,5 @@ function InfoLoader({
function useDisableScroll() {
const transaction = useTransactionMetadataRequest();
- return hasTransactionType(transaction, [TransactionType.predictClaim]);
+ return hasTransactionType(transaction, TRANSACTION_TYPES_DISABLE_SCROLL);
}
diff --git a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx
index 52171c611aae..de90632f0445 100644
--- a/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx
+++ b/app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.tsx
@@ -103,7 +103,7 @@ export const DepositKeyboard = memo(
{!alertMessage && hasInput && (
)}
+
+ {balanceDisplayValue}
+
-
-
- {balanceDisplayValue}
-
-
-
+
);
};
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
index 5d6557c9ec49..2cd012b7856a 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts
@@ -38,8 +38,6 @@ jest.mock('../../../../util/Logger', () => ({
debug: jest.fn(),
},
}));
-jest.mock('../../../../selectors/featureFlagController/rewards');
-jest.mock('../../../../store');
jest.mock('../../../../util/address', () => ({
isHardwareAccount: jest.fn(),
}));
@@ -75,8 +73,6 @@ jest.mock('ethers/lib/utils', () => ({
}));
// Import mocked modules
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
-import { store } from '../../../../store';
import { InternalAccount } from '@metamask/keyring-internal-api';
import { isHardwareAccount } from '../../../../util/address';
import { isNonEvmAddress } from '../../../Multichain/utils';
@@ -99,11 +95,7 @@ import {
} from './services/rewards-data-service';
// Type the mocked modules
-const mockSelectRewardsEnabledFlag =
- selectRewardsEnabledFlag as jest.MockedFunction<
- typeof selectRewardsEnabledFlag
- >;
-const mockStore = store as jest.Mocked;
+// Note: mockStore is kept for potential future use but currently unused after feature flag removal
const mockIsHardwareAccount = isHardwareAccount as jest.MockedFunction<
typeof isHardwareAccount
>;
@@ -364,9 +356,6 @@ describe('RewardsController', () => {
// Mock Date.now to return a consistent timestamp
jest.spyOn(Date, 'now').mockReturnValue(123);
- // Mock feature flag to be enabled by default for all tests
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
-
// Reset import mocks
// @ts-expect-error TODO: Resolve type mismatch
mockStoreSubscriptionToken.mockResolvedValue(undefined);
@@ -406,9 +395,6 @@ describe('RewardsController', () => {
unsubscribe: jest.fn(),
} as unknown as jest.Mocked;
- // Reset feature flag to enabled by default
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
-
controller = new RewardsController({
messenger: mockMessenger,
isDisabled: () => false,
@@ -522,10 +508,16 @@ describe('RewardsController', () => {
});
describe('getHasAccountOptedIn', () => {
- it('should return false when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ it('should return false when disabled via isDisabled callback', async () => {
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
- const result = await controller.getHasAccountOptedIn(CAIP_ACCOUNT_1);
+ const result =
+ await disabledController.getHasAccountOptedIn(CAIP_ACCOUNT_1);
expect(result).toBe(false);
expect(mockMessenger.call).not.toHaveBeenCalledWith(
@@ -765,8 +757,13 @@ describe('RewardsController', () => {
});
describe('estimatePoints', () => {
- it('should return default response when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ it('should return default response when disabled via isDisabled callback', async () => {
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
const mockRequest = {
activityType: 'SWAP' as const,
@@ -774,7 +771,7 @@ describe('RewardsController', () => {
activityContext: {},
};
- const result = await controller.estimatePoints(mockRequest);
+ const result = await disabledController.estimatePoints(mockRequest);
expect(result).toEqual({ pointsEstimate: 0, bonusBips: 0 });
expect(mockMessenger.call).not.toHaveBeenCalledWith(
@@ -854,8 +851,13 @@ describe('RewardsController', () => {
});
describe('getPointsEvents', () => {
- it('should return empty response when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ it('should return empty response when disabled via isDisabled callback', async () => {
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
const mockRequest = {
seasonId: 'current',
@@ -863,7 +865,7 @@ describe('RewardsController', () => {
cursor: null,
};
- const result = await controller.getPointsEvents(mockRequest);
+ const result = await disabledController.getPointsEvents(mockRequest);
expect(result).toEqual({
has_more: false,
@@ -1853,11 +1855,16 @@ describe('RewardsController', () => {
});
describe('getPerpsDiscountForAccount', () => {
- it('should return 0 when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ it('should return 0 when disabled via isDisabled callback', async () => {
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
const result =
- await controller.getPerpsDiscountForAccount(CAIP_ACCOUNT_1);
+ await disabledController.getPerpsDiscountForAccount(CAIP_ACCOUNT_1);
expect(result).toBe(0);
});
@@ -2253,33 +2260,23 @@ describe('RewardsController', () => {
});
describe('isRewardsFeatureEnabled', () => {
- it('should return true when feature flag is enabled', () => {
- // Mock the feature flag selector to return true
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
-
+ it('should return true when not disabled', () => {
const result = controller.isRewardsFeatureEnabled();
expect(result).toBe(true);
- expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled();
});
- it('should return false when feature flag is disabled', () => {
- // Mock the feature flag selector to return false
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ it('should return false when disabled via isDisabled callback', () => {
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
- const result = controller.isRewardsFeatureEnabled();
+ const result = disabledController.isRewardsFeatureEnabled();
expect(result).toBe(false);
- expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled();
- });
-
- it('should call selectRewardsEnabledFlag with store state', () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
-
- controller.isRewardsFeatureEnabled();
-
- expect(mockStore.getState).toHaveBeenCalled();
- expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled();
});
});
@@ -2302,7 +2299,6 @@ describe('RewardsController', () => {
};
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockIsHardwareAccount.mockReturnValue(false);
mockIsSolanaAddress.mockReturnValue(false);
@@ -2715,14 +2711,14 @@ describe('RewardsController', () => {
const mockSeasonId = 'season123';
const mockSubscriptionId = 'sub123';
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return null when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
- const result = await controller.getSeasonStatus(
+ const result = await disabledController.getSeasonStatus(
mockSubscriptionId,
mockSeasonId,
);
@@ -4198,10 +4194,8 @@ describe('RewardsController', () => {
describe('getSeasonMetadata', () => {
const mockSeasonId = 'season123';
- it('returns null when rewards feature is not enabled', async () => {
- // Mock the feature flag to be disabled
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
-
+ it('returns null when disabled via isDisabled callback', async () => {
+ const isDisabled = () => true;
controller = new RewardsController({
messenger: mockMessenger,
state: {
@@ -4213,12 +4207,12 @@ describe('RewardsController', () => {
seasonStatuses: {},
pointsEvents: {},
},
+ isDisabled,
});
const result = await controller.getSeasonMetadata('current');
expect(result).toBeNull();
- expect(mockSelectRewardsEnabledFlag).toHaveBeenCalled();
expect(mockMessenger.call).not.toHaveBeenCalled();
});
@@ -4668,14 +4662,8 @@ describe('RewardsController', () => {
const mockSubscriptionId = 'sub123';
const mockSeasonId = 'season456';
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('returns null when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
-
- controller = new RewardsController({
+ const disabledController = new RewardsController({
messenger: mockMessenger,
state: {
activeAccount: null,
@@ -4688,9 +4676,10 @@ describe('RewardsController', () => {
unlockedRewards: {},
pointsEvents: {},
},
+ isDisabled: () => true,
});
- const result = await controller.getReferralDetails(
+ const result = await disabledController.getReferralDetails(
mockSubscriptionId,
mockSeasonId,
);
@@ -4933,7 +4922,6 @@ describe('RewardsController', () => {
describe('handleAuthenticationTrigger', () => {
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockIsHardwareAccount.mockReturnValue(false);
mockIsSolanaAddress.mockReturnValue(false);
});
@@ -5457,7 +5445,6 @@ describe('RewardsController', () => {
describe('shouldSkipSilentAuth', () => {
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockIsHardwareAccount.mockReturnValue(false);
mockIsSolanaAddress.mockReturnValue(false);
});
@@ -5787,16 +5774,17 @@ describe('RewardsController', () => {
// Removed outdated 'reset' tests; behavior covered by 'resetAll' and 'logout' tests
describe('logout', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
- it('should skip logout when feature flag is disabled', async () => {
+ it('should skip logout when disabled via isDisabled callback', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const isDisabled = () => true;
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
+ });
// Act
- await controller.logout();
+ await disabledController.logout();
// Assert - Should not call logout service
expect(mockMessenger.call).not.toHaveBeenCalledWith(
@@ -6010,13 +5998,8 @@ describe('RewardsController', () => {
});
describe('resetAll', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should skip reset when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
const mockSubscriptionId = 'sub-abc';
const activeAccountState = {
account: CAIP_ACCOUNT_1,
@@ -6044,7 +6027,7 @@ describe('RewardsController', () => {
seasonStatuses: {},
pointsEvents: {},
},
- isDisabled: () => false,
+ isDisabled: () => true,
});
// Act
@@ -6161,16 +6144,16 @@ describe('RewardsController', () => {
});
describe('validateReferralCode', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return false when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
// Act
- const result = await controller.validateReferralCode('ABC123');
+ const result = await disabledController.validateReferralCode('ABC123');
// Assert
expect(result).toBe(false);
@@ -6269,10 +6252,6 @@ describe('RewardsController', () => {
});
describe('calculateTierStatus', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should throw error when current tier ID is not found in season tiers', () => {
// Arrange
const tiers = createTestTiers();
@@ -6416,10 +6395,6 @@ describe('RewardsController', () => {
});
describe('convertToSeasonStatusDto', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should convert SeasonDtoState and SeasonStateDto to SeasonStatusDto with all required fields', () => {
// Arrange
const tiers = createTestTiers();
@@ -6627,10 +6602,6 @@ describe('RewardsController', () => {
});
describe('convertInternalAccountToCaipAccountId', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should log error when conversion fails due to invalid internal account', () => {
// Arrange
const invalidInternalAccount = {
@@ -7153,16 +7124,16 @@ describe('RewardsController', () => {
});
describe('getGeoRewardsMetadata', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return default metadata when rewards feature is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
// Act
- const result = await controller.getGeoRewardsMetadata();
+ const result = await disabledController.getGeoRewardsMetadata();
// Assert
expect(result).toEqual({
@@ -7339,7 +7310,6 @@ describe('RewardsController', () => {
};
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Clear calls resulting from top-level `beforeEach`
jest.clearAllMocks();
});
@@ -7421,11 +7391,14 @@ describe('RewardsController', () => {
it('should return null when rewards feature is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
- const mockAccounts = [mockEvmInternalAccount];
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
// Act
- const result = await controller.optIn(mockAccounts);
+ const result = await disabledController.optIn([]);
// Assert
expect(result).toBeNull();
@@ -7835,10 +7808,16 @@ describe('RewardsController', () => {
const mockAccounts = [mockEvmInternalAccount];
// Mock rewards disabled check inside #optIn
- mockSelectRewardsEnabledFlag.mockImplementation(() => {
- // First call (in main optIn) returns true, second call (in #optIn) returns false
- const callCount = mockSelectRewardsEnabledFlag.mock.calls.length;
- return callCount === 1;
+ // Test with isDisabled callback that returns false initially, then true
+ let callCount = 0;
+ const isDisabled = () => {
+ callCount++;
+ return callCount === 2; // Second call returns true (disabled)
+ };
+ const testController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled,
});
mockMessenger.call.mockImplementation((_, ..._args): any =>
@@ -7851,7 +7830,7 @@ describe('RewardsController', () => {
}));
// Act & Assert
- await expect(controller.optIn(mockAccounts)).rejects.toThrow(
+ await expect(testController.optIn(mockAccounts)).rejects.toThrow(
'Failed to opt in any account from the account group',
);
});
@@ -8058,10 +8037,6 @@ describe('RewardsController', () => {
},
} as unknown as InternalAccount;
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return empty array when accounts array is empty', async () => {
// Act
const result = await controller.linkAccountsToSubscriptionCandidate([]);
@@ -8072,7 +8047,6 @@ describe('RewardsController', () => {
it('should return all accounts as failed when rewards feature is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
const accounts = [mockEvmInternalAccount, mockEvmInternalAccount2];
// Act
@@ -8308,10 +8282,6 @@ describe('RewardsController', () => {
});
describe('optOut', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return false when subscription ID is not found', async () => {
// Arrange
const testController = new TestableRewardsController({
@@ -8551,10 +8521,6 @@ describe('RewardsController', () => {
});
describe('optIn and optOut edge cases', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
describe('optIn edge cases', () => {
it('should handle empty account group gracefully', async () => {
// Arrange
@@ -8882,13 +8848,8 @@ describe('RewardsController', () => {
});
describe('getCandidateSubscriptionId', () => {
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return null when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
// Act
const result = await controller.getCandidateSubscriptionId();
@@ -9094,9 +9055,8 @@ describe('RewardsController', () => {
// Create a test controller with a custom implementation
class TestRewardsController extends RewardsController {
async getCandidateSubscriptionId(): Promise {
- // Mock the first part of the method to return null for active account and subscriptions
- const rewardsEnabled = selectRewardsEnabledFlag(store.getState());
- if (!rewardsEnabled) {
+ // Mock the first part of the method to return null when disabled
+ if (this.isRewardsFeatureEnabled() === false) {
return null;
}
@@ -9948,17 +9908,20 @@ describe('RewardsController', () => {
} as InternalAccount;
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockIsSolanaAddress.mockReturnValue(false); // Default to non-Solana
});
it('should return false when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
// Act
const result =
- await controller.linkAccountToSubscriptionCandidate(
+ await disabledController.linkAccountToSubscriptionCandidate(
mockInternalAccount,
);
@@ -10910,18 +10873,21 @@ describe('RewardsController', () => {
} as InternalAccount;
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockIsSolanaAddress.mockReturnValue(false);
});
it('should return all accounts as failed when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
const accounts = [mockInternalAccount1, mockInternalAccount2];
// Act
const result =
- await controller.linkAccountsToSubscriptionCandidate(accounts);
+ await disabledController.linkAccountsToSubscriptionCandidate(accounts);
// Assert
expect(result).toEqual([
@@ -10994,16 +10960,16 @@ describe('RewardsController', () => {
const mockParams = { addresses: ['0x123', '0x456'] };
const mockResponse = { ois: [true, false], sids: ['sub_123', null] };
- beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
- });
-
it('should return false array when feature flag is disabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
// Act
- const result = await controller.getOptInStatus(mockParams);
+ const result = await disabledController.getOptInStatus(mockParams);
// Assert
expect(result).toEqual({ ois: [false, false], sids: [null, null] });
@@ -11626,7 +11592,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -11654,7 +11619,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockEmptyBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -11674,7 +11638,6 @@ describe('RewardsController', () => {
const mockError = new Error('Data service error');
mockMessenger.call.mockRejectedValue(mockError);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act & Assert
await expect(
@@ -11695,7 +11658,6 @@ describe('RewardsController', () => {
const timeoutError = new Error('Request timeout after 10000ms');
mockMessenger.call.mockRejectedValue(timeoutError);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act & Assert
await expect(
@@ -11710,7 +11672,6 @@ describe('RewardsController', () => {
const authError = new Error('Authentication failed');
mockMessenger.call.mockRejectedValue(authError);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act & Assert
await expect(
@@ -11738,7 +11699,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -11758,12 +11718,16 @@ describe('RewardsController', () => {
it('should return empty array when rewards feature is disabled', async () => {
// Arrange
+ const disabledController = new RewardsController({
+ messenger: mockMessenger,
+ state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
+ });
const seasonId = 'season-123';
const subscriptionId = 'sub-456';
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
// Act
- const result = await controller.getActivePointsBoosts(
+ const result = await disabledController.getActivePointsBoosts(
seasonId,
subscriptionId,
);
@@ -11830,8 +11794,6 @@ describe('RewardsController', () => {
},
});
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
-
// Act
const result = await controller.getActivePointsBoosts(
seasonId,
@@ -11925,7 +11887,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockFreshBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -11994,7 +11955,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockFreshBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -12057,7 +12017,6 @@ describe('RewardsController', () => {
const mockResponse = { boosts: mockBoosts };
mockMessenger.call.mockResolvedValue(mockResponse);
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
// Act
const result = await controller.getActivePointsBoosts(
@@ -12154,7 +12113,6 @@ describe('RewardsController', () => {
// Clear any calls made during controller initialization
mockMessenger.call.mockClear();
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
const mockResponse2 = { boosts: mockBoosts2 };
mockMessenger.call.mockResolvedValue(mockResponse2);
@@ -12213,19 +12171,16 @@ describe('RewardsController', () => {
registerInitialEventPayload: jest.fn(),
unsubscribe: jest.fn(),
} as unknown as jest.Mocked;
-
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
});
it('should return empty array when feature flag is disabled', async () => {
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
-
- controller = new RewardsController({
+ const disabledController = new RewardsController({
messenger: mockMessenger,
state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
});
- const result = await controller.getUnlockedRewards(
+ const result = await disabledController.getUnlockedRewards(
mockSeasonId,
mockSubscriptionId,
);
@@ -12603,7 +12558,6 @@ describe('RewardsController', () => {
const mockSubscriptionId = 'test-subscription-id';
beforeEach(() => {
- mockSelectRewardsEnabledFlag.mockReturnValue(true);
mockMessenger.call.mockClear();
mockMessenger.publish.mockClear();
mockLogger.log.mockClear();
@@ -12764,15 +12718,15 @@ describe('RewardsController', () => {
it('should throw error when rewards are not enabled', async () => {
// Arrange
- mockSelectRewardsEnabledFlag.mockReturnValue(false);
- controller = new RewardsController({
+ const disabledController = new RewardsController({
messenger: mockMessenger,
state: getRewardsControllerDefaultState(),
+ isDisabled: () => true,
});
// Act & Assert
await expect(
- controller.claimReward(mockRewardId, mockSubscriptionId),
+ disabledController.claimReward(mockRewardId, mockSubscriptionId),
).rejects.toThrow('Rewards are not enabled');
expect(mockMessenger.call).not.toHaveBeenCalled();
diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
index b35b4db97002..762d98703162 100644
--- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts
+++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts
@@ -40,8 +40,6 @@ import Logger from '../../../../util/Logger';
import type { InternalAccount } from '@metamask/keyring-internal-api';
import { isAddress as isSolanaAddress } from '@solana/addresses';
import { isHardwareAccount } from '../../../../util/address';
-import { selectRewardsEnabledFlag } from '../../../../selectors/featureFlagController/rewards';
-import { store } from '../../../../store';
import {
CaipAccountId,
parseCaipChainId,
@@ -1602,13 +1600,13 @@ export class RewardsController extends BaseController<
}
/**
- * Check if the rewards feature is enabled via feature flag
+ * Check if the rewards feature is enabled
* @returns boolean - True if rewards feature is enabled, false otherwise
*/
isRewardsFeatureEnabled(): boolean {
const isDisabled = this.#isDisabled();
if (isDisabled) return false;
- return selectRewardsEnabledFlag(store.getState());
+ return true;
}
/**
diff --git a/app/images/rewards/metamask-rewards-points-alternative.svg b/app/images/rewards/metamask-rewards-points-alternative.svg
new file mode 100644
index 000000000000..936341681cbc
--- /dev/null
+++ b/app/images/rewards/metamask-rewards-points-alternative.svg
@@ -0,0 +1,3 @@
+
diff --git a/app/selectors/featureFlagController/rewards/index.test.ts b/app/selectors/featureFlagController/rewards/index.test.ts
index 3842963ef41c..cc50455664fb 100644
--- a/app/selectors/featureFlagController/rewards/index.test.ts
+++ b/app/selectors/featureFlagController/rewards/index.test.ts
@@ -1,5 +1,4 @@
import {
- selectRewardsEnabledFlag,
selectRewardsAnnouncementModalEnabledFlag,
selectRewardsCardSpendFeatureFlags,
selectRewardsMusdDepositEnabledFlag,
@@ -31,92 +30,6 @@ describe('Rewards Feature Flag Selectors', () => {
mockHasMinimumRequiredVersion?.mockRestore();
});
- describe('selectRewardsEnabledFlag', () => {
- it('returns false when basic functionality is disabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc(
- {
- rewardsEnabled: {
- enabled: true,
- minimumVersion: '1.0.0',
- },
- },
- false,
- );
-
- expect(result).toBe(false);
- });
-
- it('returns true when remote flag is valid and enabled and basic functionality is enabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc(
- {
- rewardsEnabled: {
- enabled: true,
- minimumVersion: '1.0.0',
- },
- },
- true,
- );
-
- expect(result).toBe(true);
- });
-
- it('returns false when remote flag is valid but disabled and basic functionality is enabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc(
- {
- rewardsEnabled: {
- enabled: false,
- minimumVersion: '1.0.0',
- },
- },
- true,
- );
-
- expect(result).toBe(false);
- });
-
- it('returns false when version check fails and basic functionality is enabled', () => {
- mockHasMinimumRequiredVersion.mockReturnValue(false);
-
- const result = selectRewardsEnabledFlag.resultFunc(
- {
- rewardsEnabled: {
- enabled: true,
- minimumVersion: '99.0.0',
- },
- },
- true,
- );
-
- expect(result).toBe(false);
- });
-
- it('returns false when remote flag is invalid and basic functionality is enabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc(
- {
- rewardsEnabled: {
- enabled: 'invalid',
- minimumVersion: 123,
- },
- },
- true,
- );
-
- expect(result).toBe(false);
- });
-
- it('returns false when remote feature flags are empty and basic functionality is enabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc({}, true);
-
- expect(result).toBe(false);
- });
-
- it('returns false when remote feature flags are empty and basic functionality is disabled', () => {
- const result = selectRewardsEnabledFlag.resultFunc({}, false);
-
- expect(result).toBe(false);
- });
- });
-
describe('selectRewardsAnnouncementModalEnabledFlag', () => {
it('returns true when remote flag is valid and enabled', () => {
const result = selectRewardsAnnouncementModalEnabledFlag.resultFunc({
diff --git a/app/selectors/featureFlagController/rewards/index.ts b/app/selectors/featureFlagController/rewards/index.ts
index 3c65b3a6e428..275ae27c7cf3 100644
--- a/app/selectors/featureFlagController/rewards/index.ts
+++ b/app/selectors/featureFlagController/rewards/index.ts
@@ -5,50 +5,27 @@ import {
validatedVersionGatedFeatureFlag,
VersionGatedFeatureFlag,
} from '../../../util/remoteFeatureFlag';
-import { selectBasicFunctionalityEnabled } from '../../settings';
-const DEFAULT_REWARDS_ENABLED = false;
+const DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED = false;
const DEFAULT_CARD_SPEND_ENABLED = false;
const DEFAULT_MUSD_DEPOSIT_ENABLED = false;
-export const FEATURE_FLAG_NAME = 'rewardsEnabled';
export const ANNOUNCEMENT_MODAL_FLAG_NAME = 'rewardsAnnouncementModalEnabled';
export const CARD_SPEND_FLAG_NAME = 'rewardsEnableCardSpend';
export const MUSD_DEPOSIT_FLAG_NAME = 'rewardsEnableMusdDeposit';
-export const selectRewardsEnabledFlag = createSelector(
- selectRemoteFeatureFlags,
- selectBasicFunctionalityEnabled,
- (remoteFeatureFlags, isBasicFunctionalityEnabled) => {
- // If basic functionality is disabled, rewards should be disabled
- if (!isBasicFunctionalityEnabled) {
- return false;
- }
-
- if (!hasProperty(remoteFeatureFlags, FEATURE_FLAG_NAME)) {
- return DEFAULT_REWARDS_ENABLED;
- }
- const remoteFlag = remoteFeatureFlags[
- FEATURE_FLAG_NAME
- ] as unknown as VersionGatedFeatureFlag;
-
- return (
- validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED
- );
- },
-);
-
export const selectRewardsAnnouncementModalEnabledFlag = createSelector(
selectRemoteFeatureFlags,
(remoteFeatureFlags) => {
if (!hasProperty(remoteFeatureFlags, ANNOUNCEMENT_MODAL_FLAG_NAME)) {
- return DEFAULT_REWARDS_ENABLED;
+ return DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED;
}
const remoteFlag = remoteFeatureFlags[
ANNOUNCEMENT_MODAL_FLAG_NAME
] as unknown as VersionGatedFeatureFlag;
return (
- validatedVersionGatedFeatureFlag(remoteFlag) ?? DEFAULT_REWARDS_ENABLED
+ validatedVersionGatedFeatureFlag(remoteFlag) ??
+ DEFAULT_REWARDS_ANNOUNCEMENT_MODAL_ENABLED
);
},
);
diff --git a/app/util/number/index.js b/app/util/number/index.js
index ec03320d9b18..fc9d02a68ab6 100644
--- a/app/util/number/index.js
+++ b/app/util/number/index.js
@@ -291,6 +291,25 @@ export function limitToMaximumDecimalPlaces(num, maxDecimalPlaces = 5) {
return (Math.round(num * base) / base).toString();
}
+/**
+ * Minimum display threshold for small values
+ */
+export const MINIMUM_DISPLAY_THRESHOLD = 0.00001;
+
+/**
+ * Formats a number with decimal capping and threshold handling.
+ * Shows "< 0.00001" for very small positive values, otherwise caps at maxDecimalPlaces.
+ * @param {number} num - The number to format
+ * @param {number} maxDecimalPlaces - Maximum decimal places to show (default 5)
+ * @returns {string} - Formatted number string
+ */
+export function formatAmountWithThreshold(num, maxDecimalPlaces = 5) {
+ if (num < MINIMUM_DISPLAY_THRESHOLD && num > 0) {
+ return `< ${MINIMUM_DISPLAY_THRESHOLD}`;
+ }
+ return limitToMaximumDecimalPlaces(num, maxDecimalPlaces);
+}
+
/**
* Converts fiat number as human-readable fiat string to token miniml unit expressed as a BN
*
diff --git a/app/util/transactions/index.js b/app/util/transactions/index.js
index a5217a4c44e1..35cf5fe40682 100644
--- a/app/util/transactions/index.js
+++ b/app/util/transactions/index.js
@@ -643,6 +643,28 @@ export function isTransactionIncomplete(status) {
*/
export async function getActionKey(tx, selectedAddress, ticker, chainId) {
const actionKey = await getTransactionActionKey(tx, chainId);
+
+ // Handle token transfers with direction logic (similar to ETH transfers)
+ if (actionKey === SEND_TOKEN_ACTION_KEY) {
+ const fromAddress = safeToChecksumAddress(tx.txParams.from)?.toLowerCase();
+ const toAddress = safeToChecksumAddress(tx.txParams.to)?.toLowerCase();
+ const selectedAddr = selectedAddress?.toLowerCase();
+
+ const sentByUser = fromAddress === selectedAddr;
+ const incoming = !sentByUser;
+ const selfSent = fromAddress === selectedAddr && toAddress === selectedAddr;
+
+ if (selfSent) {
+ return strings('transactions.self_sent_tokens');
+ }
+
+ if (incoming) {
+ return strings('transactions.received_tokens');
+ }
+
+ return strings('transactions.sent_tokens');
+ }
+
if (actionKey === SEND_ETHER_ACTION_KEY) {
let currencySymbol = ticker;
diff --git a/e2e/pages/Settings/SettingsView.ts b/e2e/pages/Settings/SettingsView.ts
index 3999f28579b6..ef908b1eab11 100644
--- a/e2e/pages/Settings/SettingsView.ts
+++ b/e2e/pages/Settings/SettingsView.ts
@@ -5,6 +5,7 @@ import {
SettingsViewSelectorsText,
} from '../../selectors/Settings/SettingsView.selectors';
import { CommonSelectorsText } from '../../selectors/Common.selectors';
+import { NetworksViewSelectorsIDs } from '../../selectors/Settings/NetworksView.selectors';
class SettingsView {
get title(): DetoxElement {
@@ -193,6 +194,16 @@ class SettingsView {
elemDescription: 'Settings - Snaps Button',
});
}
+
+ get closeButton(): DetoxElement {
+ return Matchers.getElementByID(NetworksViewSelectorsIDs.CLOSE_ICON);
+ }
+
+ async tapCloseButton(): Promise {
+ await Gestures.tap(this.closeButton, {
+ elemDescription: 'Settings - Close Button',
+ });
+ }
}
export default new SettingsView();
diff --git a/e2e/pages/wallet/TabBarComponent.ts b/e2e/pages/wallet/TabBarComponent.ts
index 22974b44b57c..7d9cb757e916 100644
--- a/e2e/pages/wallet/TabBarComponent.ts
+++ b/e2e/pages/wallet/TabBarComponent.ts
@@ -81,9 +81,9 @@ class TabBarComponent {
async tapSettings(): Promise {
await Utilities.executeWithRetry(
async () => {
- await Gestures.waitAndTap(this.tabBarSettingButton, {
- elemDescription: 'Tab Bar - Settings Button',
- });
+ // Ensure we're on WalletView where the hamburger menu is located
+ await this.tapWallet();
+ await WalletView.tapHamburgerMenu();
await Assertions.expectElementToBeVisible(SettingsView.title);
},
{
diff --git a/e2e/pages/wallet/WalletView.ts b/e2e/pages/wallet/WalletView.ts
index 8ce927f3422d..f67a850a068e 100644
--- a/e2e/pages/wallet/WalletView.ts
+++ b/e2e/pages/wallet/WalletView.ts
@@ -51,6 +51,12 @@ class WalletView {
);
}
+ get hamburgerMenuButton(): DetoxElement {
+ return Matchers.getElementByID(
+ WalletViewSelectorsIDs.WALLET_HAMBURGER_MENU_BUTTON,
+ );
+ }
+
get navbarNetworkText(): DetoxElement {
return Matchers.getElementByID(WalletViewSelectorsIDs.NAVBAR_NETWORK_TEXT);
}
@@ -220,6 +226,12 @@ class WalletView {
});
}
+ async tapHamburgerMenu(): Promise {
+ await Gestures.waitAndTap(this.hamburgerMenuButton, {
+ elemDescription: 'Hamburger Menu Button',
+ });
+ }
+
async tapNetworksButtonOnNavBar(): Promise {
await TestHelpers.tap(WalletViewSelectorsIDs.NAVBAR_NETWORK_BUTTON);
}
diff --git a/e2e/selectors/wallet/WalletView.selectors.ts b/e2e/selectors/wallet/WalletView.selectors.ts
index f2edfc01b53c..eb4a67864c4e 100644
--- a/e2e/selectors/wallet/WalletView.selectors.ts
+++ b/e2e/selectors/wallet/WalletView.selectors.ts
@@ -6,6 +6,7 @@ export const WalletViewSelectorsIDs = {
NFT_CONTAINER: 'collectible-name',
WALLET_SCAN_BUTTON: 'wallet-scan-button',
WALLET_NOTIFICATIONS_BUTTON: 'wallet-notifications-button',
+ WALLET_HAMBURGER_MENU_BUTTON: 'navbar-hamburger-menu-button',
WALLET_TOKEN_DETECTION_LINK_BUTTON: 'wallet-token-detection-link-button',
TOTAL_BALANCE_TEXT: 'total-balance-text',
CARD_BUTTON: 'card-button',
diff --git a/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts b/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts
index c026c6c82922..94e14547315c 100644
--- a/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts
+++ b/e2e/specs/identity/account-syncing/account-syncing-settings-toggle.spec.ts
@@ -131,8 +131,13 @@ describe(SmokeIdentity('Account syncing - Setting'), () => {
await Assertions.expectElementToBeVisible(
SettingsView.backupAndSyncSectionButton,
);
- await TabBarComponent.tapWallet();
+ // Close settings drawer (opened from hamburger menu) to return to wallet view
+ await SettingsView.tapCloseButton();
await Assertions.expectElementToBeVisible(WalletView.container);
+ // Wait for settings drawer to fully close and tab bar to be visible
+ await Assertions.expectElementToBeVisible(
+ TabBarComponent.tabBarWalletButton,
+ );
// Create third account with sync disabled - this should NOT sync to user storage
await WalletView.tapIdenticon();
diff --git a/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts b/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts
index a54b078e738c..395dbcad58fb 100644
--- a/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts
+++ b/e2e/specs/identity/contact-syncing/contact-sync-toggle.spec.ts
@@ -12,6 +12,7 @@ import BackupAndSyncView from '../../../pages/Settings/BackupAndSyncView';
import { createUserStorageController } from '../utils/mocks.ts';
import ContactsView from '../../../pages/Settings/Contacts/ContactsView.ts';
import AddContactView from '../../../pages/Settings/Contacts/AddContactView.ts';
+import CommonView from '../../../pages/CommonView.ts';
describe(SmokeIdentity('Contacts syncing - Settings'), () => {
let sharedUserStorageController: UserStorageMockttpController;
@@ -87,6 +88,8 @@ describe(SmokeIdentity('Contacts syncing - Settings'), () => {
await SettingsView.tapContacts();
await Assertions.expectElementToBeVisible(ContactsView.container);
await ContactsView.expectContactIsVisible(TEST_CONTACT_NAME);
+ await CommonView.tapBackButton();
+ await SettingsView.tapCloseButton();
// Disable contact syncing
await TabBarComponent.tapSettings();
@@ -106,6 +109,9 @@ describe(SmokeIdentity('Contacts syncing - Settings'), () => {
BackupAndSyncView.contactSyncToggle,
);
+ await CommonView.tapBackButton();
+ await SettingsView.tapCloseButton();
+
// Add second contact while sync is disabled
await TabBarComponent.tapSettings();
await Assertions.expectElementToBeVisible(
diff --git a/locales/languages/en.json b/locales/languages/en.json
index ce365af8e981..22c95cb9abd4 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3475,6 +3475,7 @@
"self_sent_dai": "Sent Yourself DAI",
"received_dai": "Received DAI",
"sent_tokens": "Sent Tokens",
+ "received_tokens": "Received Tokens",
"ether": "Ether",
"sent_unit": "Sent {{unit}}",
"self_sent_unit": "Sent Yourself {{unit}}",
@@ -5842,7 +5843,7 @@
"nested_transaction_heading": "Transaction {{index}}",
"transaction": "Transaction",
"available_balance": "Available: ",
- "edit_amount_done": "Done",
+ "edit_amount_done": "Continue",
"deposit_edit_amount_done": "Add funds",
"deposit_edit_amount_predict_withdraw": "Withdraw"
},
@@ -6813,7 +6814,8 @@
"unsupported": "Unsupported",
"tracked_count": "{{optedIn}}/{{total}} tracked",
"link_account_success": "{{accountName}} added successfully",
- "link_account_error": "Failed to add one or more addresses for {{accountName}}"
+ "link_account_error": "Failed to add one or more addresses for {{accountName}}",
+ "link_account_address_error": "Failed to add {{address}}"
},
"active_boosts_title": "Active boosts",
"season_1": "Season 1",
diff --git a/package.json b/package.json
index 7938352f9634..03cf760a6a7d 100644
--- a/package.json
+++ b/package.json
@@ -198,7 +198,7 @@
"@metamask/approval-controller": "^8.0.0",
"@metamask/assets-controllers": "^88.0.0",
"@metamask/base-controller": "^9.0.0",
- "@metamask/bitcoin-wallet-snap": "^1.5.0",
+ "@metamask/bitcoin-wallet-snap": "^1.6.0",
"@metamask/bridge-controller": "^60.1.0",
"@metamask/bridge-status-controller": "^60.1.0",
"@metamask/chain-agnostic-permission": "^1.2.2",
@@ -287,7 +287,7 @@
"@metamask/token-search-discovery-controller": "^4.0.0",
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A61.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch",
"@metamask/transaction-pay-controller": "^6.0.0",
- "@metamask/tron-wallet-snap": "^1.7.2",
+ "@metamask/tron-wallet-snap": "^1.8.0",
"@metamask/utils": "^11.8.1",
"@ngraveio/bc-ur": "^1.1.6",
"@nktkas/hyperliquid": "^0.25.9",
diff --git a/yarn.lock b/yarn.lock
index 470e908ecba8..0526f8d68d35 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7047,10 +7047,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/bitcoin-wallet-snap@npm:^1.5.0":
- version: 1.5.0
- resolution: "@metamask/bitcoin-wallet-snap@npm:1.5.0"
- checksum: 10/39a1b26132ceaf676a069295b1b2307d80ec6e80db41bde0cf600593a4fcbe880fd29b8740ddea3205ba69b7beb73c0cd845a4916281a240afae9aa24e9a2ae9
+"@metamask/bitcoin-wallet-snap@npm:^1.6.0":
+ version: 1.6.0
+ resolution: "@metamask/bitcoin-wallet-snap@npm:1.6.0"
+ checksum: 10/e5d391ecc88c52fa56b888e0a341331da8c8fec18a228ae3238f9ace9c0216012ef2af06134cab25fe251e6e829a14db706aa8d01ed70976fe47fd40017a6c8d
languageName: node
linkType: hard
@@ -8936,10 +8936,10 @@ __metadata:
languageName: node
linkType: hard
-"@metamask/tron-wallet-snap@npm:^1.7.2":
- version: 1.7.4
- resolution: "@metamask/tron-wallet-snap@npm:1.7.4"
- checksum: 10/e8e8e1eff263a3dd4d1b489f213859422029aa900bc1c334b624fa555cef7602ce3b5bd48b90b73015faea56430c2271299ffcf4f7e5cf7c8c742524aa76c6a6
+"@metamask/tron-wallet-snap@npm:^1.8.0":
+ version: 1.8.0
+ resolution: "@metamask/tron-wallet-snap@npm:1.8.0"
+ checksum: 10/26f00b353d3b443f9cdf76bcfa2e1e601042dc0ab6eb39d32666049c405b31bb6c1f5c10093091f5a7fad7e18902dda5631d1ba7b95a88acd6bfe6426c926177
languageName: node
linkType: hard
@@ -34312,7 +34312,7 @@ __metadata:
"@metamask/assets-controllers": "npm:^88.0.0"
"@metamask/auto-changelog": "npm:^5.1.0"
"@metamask/base-controller": "npm:^9.0.0"
- "@metamask/bitcoin-wallet-snap": "npm:^1.5.0"
+ "@metamask/bitcoin-wallet-snap": "npm:^1.6.0"
"@metamask/bridge-controller": "npm:^60.1.0"
"@metamask/bridge-status-controller": "npm:^60.1.0"
"@metamask/browser-passworder": "npm:^5.0.0"
@@ -34412,7 +34412,7 @@ __metadata:
"@metamask/token-search-discovery-controller": "npm:^4.0.0"
"@metamask/transaction-controller": "patch:@metamask/transaction-controller@npm%3A61.3.0#~/.yarn/patches/@metamask-transaction-controller-npm-61.0.0-cccac388c7.patch"
"@metamask/transaction-pay-controller": "npm:^6.0.0"
- "@metamask/tron-wallet-snap": "npm:^1.7.2"
+ "@metamask/tron-wallet-snap": "npm:^1.8.0"
"@metamask/utils": "npm:^11.8.1"
"@ngraveio/bc-ur": "npm:^1.1.6"
"@nktkas/hyperliquid": "npm:^0.25.9"