diff --git a/app/component-library/components-temp/Accounts/AccountBase/AccountBase.styles.ts b/app/component-library/components-temp/Accounts/AccountBase/AccountBase.styles.ts
index cda7d7fe248..f755c07b3eb 100644
--- a/app/component-library/components-temp/Accounts/AccountBase/AccountBase.styles.ts
+++ b/app/component-library/components-temp/Accounts/AccountBase/AccountBase.styles.ts
@@ -29,7 +29,6 @@ const styleSheet = StyleSheet.create({
justifyContent: 'flex-start',
},
accountNameLabelText: {
- marginLeft: 4,
paddingHorizontal: 8,
borderWidth: 1,
borderRadius: 8,
diff --git a/app/component-library/components-temp/Accounts/AccountBase/AccountBase.tsx b/app/component-library/components-temp/Accounts/AccountBase/AccountBase.tsx
index 14f619c98e0..88e0f809d05 100644
--- a/app/component-library/components-temp/Accounts/AccountBase/AccountBase.tsx
+++ b/app/component-library/components-temp/Accounts/AccountBase/AccountBase.tsx
@@ -49,15 +49,17 @@ const AccountBase = ({
{accountName}
- {accountTypeLabel && (
+
+ {accountTypeLabel && (
+
{strings(accountTypeLabel)}
- )}
+ )}
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index 7cc0d590781..c267c2186d1 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -1857,34 +1857,61 @@ export const getSettingsNavigationOptions = (title, themeColors) => {
};
};
-export function getStakingNavbar(title, navigation, themeColors) {
+/**
+ *
+ * @param {String} title - Navbar Title.
+ * @param {NavigationProp} navigation Navigation object returned from useNavigation hook.
+ * @param {ThemeColors} themeColors theme.colors returned from useStyles hook.
+ * @param {{ backgroundColor?: string, hasCancelButton?: boolean, hasBackButton?: boolean }} [options] - Optional options for navbar.
+ * @returns Staking Navbar Component.
+ */
+export function getStakingNavbar(title, navigation, themeColors, options) {
+ const { hasBackButton = true, hasCancelButton = true } = options ?? {};
+
const innerStyles = StyleSheet.create({
+ headerStyle: {
+ backgroundColor:
+ options?.backgroundColor ?? themeColors.background.default,
+ shadowOffset: null,
+ },
+ headerLeft: {
+ marginHorizontal: 16,
+ },
headerButtonText: {
color: themeColors.primary.default,
fontSize: 14,
...fontStyles.normal,
},
- headerStyle: {
- backgroundColor: themeColors.background.default,
- shadowColor: importedColors.transparent,
- elevation: 0,
- },
});
+
+ function navigationPop() {
+ navigation.goBack();
+ }
+
return {
headerTitle: () => (
-
- ),
- headerLeft: () => ,
- headerRight: () => (
- navigation.dangerouslyGetParent()?.pop()}
- style={styles.closeButton}
- >
-
- {strings('navigation.cancel')}
-
-
+ {title}
),
headerStyle: innerStyles.headerStyle,
+ headerLeft: () =>
+ hasBackButton ? (
+
+ ) : null,
+ headerRight: () =>
+ hasCancelButton ? (
+ navigation.dangerouslyGetParent()?.pop()}
+ style={styles.closeButton}
+ >
+
+ {strings('navigation.cancel')}
+
+
+ ) : null,
};
}
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts
new file mode 100644
index 00000000000..d351fc7302a
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts
@@ -0,0 +1,23 @@
+import type { Theme } from '../../../../../util/theme/models';
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ mainContainer: {
+ flex: 1,
+ paddingTop: 8,
+ paddingHorizontal: 16,
+ backgroundColor: colors.background.alternative,
+ justifyContent: 'space-between',
+ },
+ cardsContainer: {
+ paddingTop: 16,
+ gap: 8,
+ },
+ });
+};
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
new file mode 100644
index 00000000000..109fe3e7fac
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import StakeConfirmationView from './StakeConfirmationView';
+import { Image } from 'react-native';
+import { createMockAccountsControllerState } from '../../../../../util/test/accountsControllerTestUtils';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import configureMockStore from 'redux-mock-store';
+import { Provider } from 'react-redux';
+import { StakeConfirmationViewProps } from './StakeConfirmationView.types';
+
+jest.mock('../../../../hooks/useIpfsGateway', () => jest.fn());
+
+Image.getSize = jest.fn((_uri, success) => {
+ success(100, 100); // Mock successful response for ETH native Icon Image
+});
+
+const MOCK_ADDRESS_1 = '0x0';
+const MOCK_ADDRESS_2 = '0x1';
+
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+ MOCK_ADDRESS_2,
+]);
+
+const mockStore = configureMockStore();
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ },
+};
+const store = mockStore(mockInitialState);
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest
+ .fn()
+ .mockImplementation((callback) => callback(mockInitialState)),
+}));
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => ({
+ navigate: jest.fn(),
+ setOptions: jest.fn(),
+ }),
+ };
+});
+
+describe('StakeConfirmationView', () => {
+ it('render matches snapshot', () => {
+ const props: StakeConfirmationViewProps = {
+ route: {
+ key: '1',
+ params: { amountWei: '3210000000000000', amountFiat: '7.46' },
+ name: 'params',
+ },
+ };
+
+ const { toJSON } = renderWithProvider(
+
+
+ ,
+ );
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
new file mode 100644
index 00000000000..2f1a4890286
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
@@ -0,0 +1,60 @@
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import { useNavigation } from '@react-navigation/native';
+import { useStyles } from '../../../../hooks/useStyles';
+import { getStakingNavbar } from '../../../Navbar';
+import styleSheet from './StakeConfirmationView.styles';
+import TokenValueStack from '../../components/StakingConfirmation/TokenValueStack/TokenValueStack';
+import AccountHeaderCard from '../../components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard';
+import RewardsCard from '../../components/StakingConfirmation/RewardsCard/RewardsCard';
+import ConfirmationFooter from '../../components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter';
+import { StakeConfirmationViewProps } from './StakeConfirmationView.types';
+import { MOCK_GET_VAULT_RESPONSE } from '../../components/StakingBalance/mockData';
+import { strings } from '../../../../../../locales/i18n';
+
+const MOCK_REWARD_DATA = {
+ REWARDS: {
+ ETH: '0.13 ETH',
+ FIAT: '$334.93',
+ },
+};
+
+const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
+
+const StakeConfirmationView = ({ route }: StakeConfirmationViewProps) => {
+ const navigation = useNavigation();
+
+ const { styles, theme } = useStyles(styleSheet, {});
+
+ useEffect(() => {
+ navigation.setOptions(
+ getStakingNavbar(strings('stake.stake'), navigation, theme.colors, {
+ backgroundColor: theme.colors.background.alternative,
+ hasCancelButton: false,
+ }),
+ );
+ }, [navigation, theme.colors]);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default StakeConfirmationView;
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
new file mode 100644
index 00000000000..8c723135f4f
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
@@ -0,0 +1,10 @@
+import { RouteProp } from '@react-navigation/native';
+
+interface StakeConfirmationViewRouteParams {
+ amountWei: string;
+ amountFiat: string;
+}
+
+export interface StakeConfirmationViewProps {
+ route: RouteProp<{ params: StakeConfirmationViewRouteParams }, 'params'>;
+}
diff --git a/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
new file mode 100644
index 00000000000..9d14c100f63
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
@@ -0,0 +1,1424 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StakeConfirmationView render matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.00321
+
+ ETH
+
+
+ $7.46
+
+
+
+
+
+
+
+
+
+
+
+ Staking from
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account 1
+
+
+
+
+
+
+
+
+
+
+
+
+ Interacting with
+
+
+
+
+
+
+
+
+
+
+
+
+ MM Pooled Staking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+
+
+
+
+
+
+
+
+
+
+
+ Ethereum Main Network
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Reward rate
+
+
+
+
+
+
+
+
+
+
+
+ 2.8%
+
+
+
+
+
+
+
+
+
+
+ Estimated annual rewards
+
+
+
+
+
+
+
+
+
+ $334.93
+
+
+ 0.13 ETH
+
+
+
+
+
+
+
+
+
+
+
+ Reward frequency
+
+
+
+
+
+
+
+
+
+
+
+ 12 hours
+
+
+
+
+
+
+
+
+
+
+
+
+ Terms of service
+
+
+
+
+ Risk disclosure
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Confirm
+
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
index 1ca6560d7f4..0431e67a77f 100644
--- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
+++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx
@@ -50,8 +50,14 @@ const StakeInputView = () => {
};
const handleStakePress = useCallback(() => {
- // TODO: Display the Review bottom sheet: STAKE-824
- }, []);
+ navigation.navigate('StakeScreens', {
+ screen: Routes.STAKING.STAKE_CONFIRMATION,
+ params: {
+ amountWei: amountWei.toString(),
+ amountFiat: fiatAmount,
+ },
+ });
+ }, [amountWei, fiatAmount, navigation]);
const balanceText = strings('stake.balance');
@@ -66,7 +72,11 @@ const StakeInputView = () => {
: `${balanceFiatNumber?.toString()} ${currentCurrency.toUpperCase()}`;
useEffect(() => {
- navigation.setOptions(getStakingNavbar(title, navigation, theme.colors));
+ navigation.setOptions(
+ getStakingNavbar(title, navigation, theme.colors, {
+ hasBackButton: false,
+ }),
+ );
}, [navigation, theme.colors, title]);
useEffect(() => {
diff --git a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap
index 5085ab0c15e..4a35aa2b83e 100644
--- a/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap
+++ b/app/components/UI/Stake/Views/StakeInputView/__snapshots__/StakeInputView.test.tsx.snap
@@ -56,13 +56,9 @@ exports[`StakeInputView render matches snapshot 1`] = `
{
"backgroundColor": "#ffffff",
"borderBottomColor": "rgb(216, 216, 216)",
- "elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
- "shadowOffset": {
- "height": 0.5,
- "width": 0,
- },
+ "shadowColor": "rgb(216, 216, 216)",
+ "shadowOffset": null,
"shadowOpacity": 0.85,
"shadowRadius": 0,
}
@@ -106,96 +102,26 @@ exports[`StakeInputView render matches snapshot 1`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Stake ETH
-
-
-
-
- Ethereum Main Network
-
-
-
+ Stake ETH
+
{
: strings('stake.review');
useEffect(() => {
- navigation.setOptions(getStakingNavbar(title, navigation, theme.colors));
+ navigation.setOptions(
+ getStakingNavbar(title, navigation, theme.colors, {
+ hasBackButton: false,
+ }),
+ );
}, [navigation, theme.colors, title]);
const handleUnstakePress = useCallback(() => {
diff --git a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap
index 15e289f23e7..5e7927b0b5c 100644
--- a/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap
+++ b/app/components/UI/Stake/Views/UnstakeInputView/__snapshots__/UnstakeInputView.test.tsx.snap
@@ -56,13 +56,9 @@ exports[`UnstakeInputView render matches snapshot 1`] = `
{
"backgroundColor": "#ffffff",
"borderBottomColor": "rgb(216, 216, 216)",
- "elevation": 0,
"flex": 1,
- "shadowColor": "transparent",
- "shadowOffset": {
- "height": 0.5,
- "width": 0,
- },
+ "shadowColor": "rgb(216, 216, 216)",
+ "shadowOffset": null,
"shadowOpacity": 0.85,
"shadowRadius": 0,
}
@@ -106,96 +102,26 @@ exports[`UnstakeInputView render matches snapshot 1`] = `
pointerEvents="box-none"
style={
{
- "alignItems": "flex-start",
- "bottom": 0,
- "justifyContent": "center",
- "left": 0,
- "opacity": 1,
- "position": "absolute",
- "top": 0,
- }
- }
- >
-
-
-
-
-
- Unstake ETH
-
-
-
-
- Ethereum Main Network
-
-
-
+ Unstake ETH
+
jest.fn());
+
+Image.getSize = jest.fn((_uri, success) => {
+ success(100, 100); // Mock successful response for ETH native Icon Image
+});
const mockNavigate = jest.fn();
@@ -39,15 +29,17 @@ afterEach(() => {
});
describe('StakingBalance', () => {
+ beforeEach(() => jest.resetAllMocks());
+
it('render matches snapshot', () => {
- render(StakingBalance);
- expect(screen.toJSON()).toMatchSnapshot();
+ const { toJSON } = renderWithProvider();
+ expect(toJSON()).toMatchSnapshot();
});
it('redirects to StakeInputView on stake button click', () => {
- render(StakingBalance);
+ const { getByText } = renderWithProvider();
- fireEvent.press(screen.getByText(strings('stake.stake_more')));
+ fireEvent.press(getByText(strings('stake.stake_more')));
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
@@ -56,9 +48,9 @@ describe('StakingBalance', () => {
});
it('redirects to UnstakeInputView on unstake button click', () => {
- render(StakingBalance);
+ const { getByText } = renderWithProvider();
- fireEvent.press(screen.getByText(strings('stake.unstake')));
+ fireEvent.press(getByText(strings('stake.unstake')));
expect(mockNavigate).toHaveBeenCalledTimes(1);
expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
diff --git a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
index 1ab816b649d..13b3d2c8629 100644
--- a/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
+++ b/app/components/UI/Stake/components/StakingBalance/StakingCta/StakingCta.tsx
@@ -36,10 +36,10 @@ const StakingCta = ({ estimatedRewardRate, style }: StakingCtaProps) => {
{strings('stake.stake_your_eth_cta.base')}
- {estimatedRewardRate}
-
- {strings('stake.stake_your_eth_cta.annually')}
+
+ {estimatedRewardRate}
+ {strings('stake.stake_your_eth_cta.annually')}
+
-
+ $13,292.20
+
+
-
+
+
+
+
+
+
-
+
+
+ Unstaking 0.0010 ETH in progress. Come back in 11 days to claim it.
+
+
+
+
+
+
+
+
+
-
+
+
-
+
+
+
+
+
+ Stake ETH and earn
+
+
+
+ Stake your ETH with MetaMask Pool and earn
+
+
+ 2.9%
+
+
+ annually.
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Staked Ethereum
-
-
-
- $13,292.20
-
-
- 4.9999 ETH
-
-
-
-
-
-
-
-
-
-
- Unstaking 0.0010 ETH in progress. Come back in 11 days to claim it.
-
-
-
-
-
-
-
-
-
- You can claim 0.00214 ETH. Once claimed, you'll get ETH back in your wallet.
-
-
-
- Claim
- ETH
-
-
-
-
-
-
- Stake ETH and earn
-
-
-
- Stake your ETH with MetaMask Pool and earn
-
-
- 2.9%
-
-
- annually.
-
-
-
- Learn more.
-
-
-
-
-
-
-
- Unstake
-
-
-
-
- Stake more
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ }
+ >
+ Learn more.
+
+
+
+
+
+
+
+ Unstake
+
+
+
+
+ Stake more
+
+
+
+
`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.styles.ts
new file mode 100644
index 00000000000..3688107b7e1
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.styles.ts
@@ -0,0 +1,40 @@
+import type { Theme } from '../../../../../../util/theme/models';
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ cardGroupTop: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ },
+ cardGroupBottom: {
+ borderLeftWidth: 0,
+ borderRightWidth: 0,
+ borderBottomWidth: 0,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
+ borderColor: colors.border.muted,
+ },
+ networkKeyValueRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ tagMinimalPadding: {
+ paddingLeft: 0,
+ paddingRight: 8,
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ });
+};
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.test.tsx
new file mode 100644
index 00000000000..c8aa996b8f9
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.test.tsx
@@ -0,0 +1,72 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import AccountHeaderCard from './AccountHeaderCard';
+import { strings } from '../../../../../../../locales/i18n';
+import { createMockAccountsControllerState } from '../../../../../../util/test/accountsControllerTestUtils';
+import configureMockStore from 'redux-mock-store';
+import { backgroundState } from '../../../../../../util/test/initial-root-state';
+import { Provider } from 'react-redux';
+import { AccountHeaderCardProps } from './AccountHeaderCard.types';
+
+const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
+
+const MOCK_ADDRESS_1 = '0x0';
+const MOCK_ADDRESS_2 = '0x1';
+
+const MOCK_ACCOUNTS_CONTROLLER_STATE = createMockAccountsControllerState([
+ MOCK_ADDRESS_1,
+ MOCK_ADDRESS_2,
+]);
+
+const mockStore = configureMockStore();
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ },
+};
+const store = mockStore(mockInitialState);
+
+jest.mock('react-redux', () => ({
+ ...jest.requireActual('react-redux'),
+ useSelector: jest
+ .fn()
+ .mockImplementation((callback) => callback(mockInitialState)),
+}));
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+describe('AccountHeaderCard', () => {
+ it('render matches snapshot', () => {
+ const props: AccountHeaderCardProps = {
+ contractName: MOCK_STAKING_CONTRACT_NAME,
+ };
+
+ const { getByText, toJSON } = renderWithProvider(
+
+ ,
+ ,
+ );
+
+ expect(getByText(strings('stake.staking_from'))).toBeDefined();
+ expect(getByText(strings('stake.interacting_with'))).toBeDefined();
+ expect(getByText(strings('asset_details.network'))).toBeDefined();
+ expect(getByText(props.contractName)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.tsx b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.tsx
new file mode 100644
index 00000000000..9d4e2cb19ee
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { View } from 'react-native';
+import { useSelector } from 'react-redux';
+import { strings } from '../../../../../../../locales/i18n';
+import KeyValueRow from '../../../../../../component-library/components-temp/KeyValueRow';
+import Avatar, {
+ AvatarVariant,
+ AvatarSize,
+} from '../../../../../../component-library/components/Avatars/Avatar';
+import Text from '../../../../../../component-library/components/Texts/Text';
+import { selectSelectedInternalAccount } from '../../../../../../selectors/accountsController';
+import { useStyles } from '../../../../../hooks/useStyles';
+import Card from '../../../../../../component-library/components/Cards/Card';
+import styleSheet from './AccountHeaderCard.styles';
+import images from '../../../../../../images/image-icons';
+import AccountTag from '../AccountTag/AccountTag';
+import { selectNetworkName } from '../../../../../../selectors/networkInfos';
+import { AccountHeaderCardProps } from './AccountHeaderCard.types';
+import ContractTag from '../ContractTag/ContractTag';
+
+const AccountHeaderCard = ({ contractName }: AccountHeaderCardProps) => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const account = useSelector(selectSelectedInternalAccount);
+
+ const networkName = useSelector(selectNetworkName);
+
+ return (
+
+
+ {account && (
+
+ ),
+ }}
+ />
+ )}
+ ,
+ }}
+ />
+
+
+
+
+ {networkName}
+
+ ),
+ }}
+ />
+
+
+ );
+};
+
+export default AccountHeaderCard;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.types.ts b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.types.ts
new file mode 100644
index 00000000000..3775cc7f14a
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.types.ts
@@ -0,0 +1,3 @@
+export interface AccountHeaderCardProps {
+ contractName: string;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/__snapshots__/AccountHeaderCard.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/__snapshots__/AccountHeaderCard.test.tsx.snap
new file mode 100644
index 00000000000..261f776ab14
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/__snapshots__/AccountHeaderCard.test.tsx.snap
@@ -0,0 +1,623 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AccountHeaderCard render matches snapshot 1`] = `
+[
+
+
+
+
+
+
+
+ Staking from
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Account 1
+
+
+
+
+
+
+
+
+
+
+
+
+ Interacting with
+
+
+
+
+
+
+
+
+
+
+
+
+ MM Pooled Staking
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Network
+
+
+
+
+
+
+
+
+
+
+
+
+ Ethereum Main Network
+
+
+
+
+
+
+
+ ,
+ ",",
+]
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.test.tsx
new file mode 100644
index 00000000000..901690c1847
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.test.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import AccountTag from './AccountTag';
+import { AccountTagProps } from './AccountTag.types';
+
+describe('AccountTag', () => {
+ it('render matches snapshot when name prop is defined', () => {
+ const props: AccountTagProps = {
+ accountAddress: '0x1',
+ accountName: 'Sample Contract',
+ };
+
+ const { getByText, toJSON } = renderWithProvider();
+
+ expect(getByText(props.accountName as string)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it("render matches snapshot when name prop isn't defined", () => {
+ const props: AccountTagProps = {
+ accountAddress: '0x1',
+ };
+
+ const { getByText, toJSON } = renderWithProvider();
+
+ expect(getByText(props.accountAddress)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.tsx b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.tsx
new file mode 100644
index 00000000000..70a8986edd0
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import TagBase, {
+ TagShape,
+ TagSeverity,
+} from '../../../../../../component-library/base-components/TagBase';
+import Avatar, {
+ AvatarVariant,
+ AvatarSize,
+ AvatarAccountType,
+} from '../../../../../../component-library/components/Avatars/Avatar';
+import { AccountTagProps } from './AccountTag.types';
+
+const AccountTag = ({
+ accountAddress,
+ accountName,
+ useBlockieIcon = false,
+}: AccountTagProps) => (
+
+ }
+ shape={TagShape.Pill}
+ severity={TagSeverity.Info}
+ >
+ {accountName ?? accountAddress}
+
+);
+
+export default AccountTag;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.types.ts b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.types.ts
new file mode 100644
index 00000000000..24866d2b492
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.types.ts
@@ -0,0 +1,5 @@
+export interface AccountTagProps {
+ accountAddress: string;
+ accountName?: string;
+ useBlockieIcon?: boolean;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/AccountTag/__snapshots__/AccountTag.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/__snapshots__/AccountTag.test.tsx.snap
new file mode 100644
index 00000000000..ca2dc96db44
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/AccountTag/__snapshots__/AccountTag.test.tsx.snap
@@ -0,0 +1,395 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`AccountTag render matches snapshot when name prop is defined 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sample Contract
+
+
+
+`;
+
+exports[`AccountTag render matches snapshot when name prop isn't defined 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0x1
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.styles.ts
new file mode 100644
index 00000000000..de5fe494916
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.styles.ts
@@ -0,0 +1,114 @@
+import type { Theme } from '../../../../../../util/theme/models';
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ mainContainer: {
+ paddingTop: 8,
+ paddingHorizontal: 16,
+ backgroundColor: colors.background.alternative,
+ height: '100%',
+ justifyContent: 'space-between',
+ },
+ // Card styles
+ cardsContainer: {
+ paddingTop: 8,
+ gap: 8,
+ },
+ card: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ },
+ estGasFeeCard: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ justifyContent: 'center',
+ },
+ cardGroupTop: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ borderBottomLeftRadius: 0,
+ borderBottomRightRadius: 0,
+ },
+ cardGroupBottom: {
+ borderLeftWidth: 0,
+ borderRightWidth: 0,
+ borderBottomWidth: 0,
+ borderTopLeftRadius: 0,
+ borderTopRightRadius: 0,
+ borderBottomLeftRadius: 8,
+ borderBottomRightRadius: 8,
+ borderColor: colors.border.muted,
+ },
+ // Network
+ networkKeyValueRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ // Estimated Gas Fee
+ estGasFeeValue: {
+ flexDirection: 'row',
+ paddingTop: 1,
+ },
+ foxIcon: {
+ paddingRight: 8,
+ },
+ fiatText: {
+ paddingRight: 4,
+ },
+ ethText: {
+ borderBottomWidth: 1,
+ borderBottomColor: theme.colors.primary.default,
+ },
+ estimatedGasTooltipContent: {
+ gap: 16,
+ },
+ gasLearnMoreLink: {
+ alignSelf: 'flex-start',
+ },
+ // Est. Annual Reward
+ estAnnualRewardValue: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ // Tags
+ tagMinimalPadding: {
+ paddingLeft: 0,
+ paddingRight: 8,
+ paddingTop: 0,
+ paddingBottom: 0,
+ },
+ // Terms of Service / Risk Disclosure Button Group
+ termsOfServiceButtonGroup: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ legalLink: {
+ padding: 16,
+ },
+ // Footer Button Group
+ footerButtonGroup: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 16,
+ paddingTop: 24,
+ },
+ footerButton: {
+ flexGrow: 1,
+ flexShrink: 0,
+ flexBasis: 0,
+ },
+ footerContainer: {
+ paddingBottom: 40,
+ },
+ });
+};
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.test.tsx
new file mode 100644
index 00000000000..36db2a000b1
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.test.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import ConfirmationFooter from './ConfirmationFooter';
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: jest.fn(),
+ };
+});
+
+describe('ConfirmationFooter', () => {
+ it('render matches snapshot', () => {
+ const { toJSON } = renderWithProvider();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.tsx
new file mode 100644
index 00000000000..6505cef13f7
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import { useStyles } from '../../../../../hooks/useStyles';
+import styleSheet from './ConfirmationFooter.styles';
+import { View } from 'react-native';
+import FooterLegalLinks from './LegalLinks/LegalLinks';
+import FooterButtonGroup from './FooterButtonGroup/FooterButtonGroup';
+
+const ConfirmationFooter = () => {
+ const { styles } = useStyles(styleSheet, {});
+
+ return (
+
+
+
+
+ );
+};
+
+export default ConfirmationFooter;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.styles.ts
new file mode 100644
index 00000000000..c50133567a7
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.styles.ts
@@ -0,0 +1,18 @@
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = () =>
+ StyleSheet.create({
+ footerContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ gap: 16,
+ paddingTop: 24,
+ },
+ button: {
+ flexGrow: 1,
+ flexShrink: 0,
+ flexBasis: 0,
+ },
+ });
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
new file mode 100644
index 00000000000..9f756063fa6
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
@@ -0,0 +1,49 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../../util/test/renderWithProvider';
+import { strings } from '../../../../../../../../locales/i18n';
+import FooterButtonGroup from './FooterButtonGroup';
+import { fireEvent } from '@testing-library/react-native';
+
+const mockCanGoBack = jest.fn();
+const mockGoBack = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ canGoBack: mockCanGoBack,
+ goBack: mockGoBack,
+ }),
+ };
+});
+
+describe('FooterButtonGroup', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('render matches snapshot', () => {
+ const { getByText, toJSON } = renderWithProvider();
+
+ expect(getByText(strings('stake.cancel'))).toBeDefined();
+ expect(getByText(strings('stake.confirm'))).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('navigates to previous page when cancel button is pressed', () => {
+ mockCanGoBack.mockImplementationOnce(() => true);
+
+ const { getByText, toJSON } = renderWithProvider();
+
+ fireEvent.press(getByText(strings('stake.cancel')));
+
+ expect(mockCanGoBack).toHaveBeenCalledTimes(1);
+ expect(mockGoBack).toHaveBeenCalledTimes(1);
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it.todo('confirms stake when confirm button is pressed');
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
new file mode 100644
index 00000000000..bd781bfc9a8
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
@@ -0,0 +1,59 @@
+import React from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { View } from 'react-native';
+import { strings } from '../../../../../../../../locales/i18n';
+import Button, {
+ ButtonVariants,
+ ButtonWidthTypes,
+ ButtonSize,
+} from '../../../../../../../component-library/components/Buttons/Button';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../../hooks/useStyles';
+import styleSheet from './FooterButtonGroup.styles';
+
+const FooterButtonGroup = () => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const navigation = useNavigation();
+
+ const handleGoBack = () => {
+ if (navigation.canGoBack()) {
+ navigation.goBack();
+ }
+ };
+
+ return (
+
+
+ );
+};
+
+export default FooterButtonGroup;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/__snapshots__/FooterButtonGroup.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/__snapshots__/FooterButtonGroup.test.tsx.snap
new file mode 100644
index 00000000000..df88108ac02
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/__snapshots__/FooterButtonGroup.test.tsx.snap
@@ -0,0 +1,189 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FooterButtonGroup navigates to previous page when cancel button is pressed 1`] = `
+
+
+
+ Cancel
+
+
+
+
+ Confirm
+
+
+
+`;
+
+exports[`FooterButtonGroup render matches snapshot 1`] = `
+
+
+
+ Cancel
+
+
+
+
+ Confirm
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.styles.ts
new file mode 100644
index 00000000000..a5ac5b5ff78
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.styles.ts
@@ -0,0 +1,14 @@
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = () =>
+ StyleSheet.create({
+ termsOfServiceButtonGroup: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ },
+ legalLink: {
+ padding: 16,
+ },
+ });
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.test.tsx
new file mode 100644
index 00000000000..5338556c843
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.test.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../../util/test/renderWithProvider';
+import FooterLegalLinks from './LegalLinks';
+import { strings } from '../../../../../../../../locales/i18n';
+import { fireEvent } from '@testing-library/react-native';
+import AppConstants from '../../../../../../../core/AppConstants';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+describe('FooterLegalLinks', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('render matches snapshot', () => {
+ const { getByText, toJSON } = renderWithProvider();
+
+ expect(getByText(strings('stake.terms_of_service'))).toBeDefined();
+ expect(getByText(strings('stake.risk_disclosure'))).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('navigates to terms of use web page', () => {
+ const { getByText, toJSON } = renderWithProvider();
+
+ fireEvent.press(getByText(strings('stake.terms_of_service')));
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('Webview', {
+ params: { url: AppConstants.URLS.TERMS_AND_CONDITIONS },
+ screen: 'SimpleWebview',
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('navigates to risk disclosure web page', () => {
+ const { getByText, toJSON } = renderWithProvider();
+
+ fireEvent.press(getByText(strings('stake.risk_disclosure')));
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('Webview', {
+ params: { url: AppConstants.URLS.STAKING_RISK_DISCLOSURE },
+ screen: 'SimpleWebview',
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.tsx
new file mode 100644
index 00000000000..4b74efd0bcc
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { View, TouchableOpacity } from 'react-native';
+import { strings } from '../../../../../../../../locales/i18n';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../../../component-library/components/Texts/Text';
+import AppConstants from '../../../../../../../core/AppConstants';
+import { useStyles } from '../../../../../../hooks/useStyles';
+import styleSheet from './LegalLinks.styles';
+
+const FooterLegalLinks = () => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const navigation = useNavigation();
+
+ const handleNavigateToWebView = (url: string) =>
+ navigation.navigate('Webview', {
+ screen: 'SimpleWebview',
+ params: { url },
+ });
+
+ return (
+
+
+ handleNavigateToWebView(AppConstants.URLS.TERMS_AND_CONDITIONS)
+ }
+ style={styles.legalLink}
+ >
+
+ {strings('stake.terms_of_service')}
+
+
+
+ handleNavigateToWebView(AppConstants.URLS.STAKING_RISK_DISCLOSURE)
+ }
+ style={styles.legalLink}
+ >
+
+ {strings('stake.risk_disclosure')}
+
+
+
+ );
+};
+
+export default FooterLegalLinks;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/__snapshots__/LegalLinks.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/__snapshots__/LegalLinks.test.tsx.snap
new file mode 100644
index 00000000000..2fc6328066f
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/__snapshots__/LegalLinks.test.tsx.snap
@@ -0,0 +1,187 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`FooterLegalLinks navigates to risk disclosure web page 1`] = `
+
+
+
+ Terms of service
+
+
+
+
+ Risk disclosure
+
+
+
+`;
+
+exports[`FooterLegalLinks navigates to terms of use web page 1`] = `
+
+
+
+ Terms of service
+
+
+
+
+ Risk disclosure
+
+
+
+`;
+
+exports[`FooterLegalLinks render matches snapshot 1`] = `
+
+
+
+ Terms of service
+
+
+
+
+ Risk disclosure
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
new file mode 100644
index 00000000000..e29a06f61c9
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
@@ -0,0 +1,162 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ConfirmationFooter render matches snapshot 1`] = `
+
+
+
+
+ Terms of service
+
+
+
+
+ Risk disclosure
+
+
+
+
+
+
+ Cancel
+
+
+
+
+ Confirm
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.test.tsx
new file mode 100644
index 00000000000..c88f586fb98
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.test.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import ContractTag from './ContractTag';
+
+const MOCK_STAKING_CONTRACT_NAME = 'MM Pooled Staking';
+
+describe('ContractTag', () => {
+ it('render matches snapshot', () => {
+ const { getByText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(getByText(MOCK_STAKING_CONTRACT_NAME)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.tsx b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.tsx
new file mode 100644
index 00000000000..4b3eb3cee57
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.tsx
@@ -0,0 +1,23 @@
+import React from 'react';
+import TagBase, {
+ TagSeverity,
+ TagShape,
+} from '../../../../../../component-library/base-components/TagBase';
+import Icon, {
+ IconName,
+ IconSize,
+} from '../../../../../../component-library/components/Icons/Icon';
+import Text from '../../../../../../component-library/components/Texts/Text';
+import { ContractTagProps } from './ContractTag.types';
+
+const ContractTag = ({ contractName }: ContractTagProps) => (
+ }
+ shape={TagShape.Pill}
+ severity={TagSeverity.Neutral}
+ >
+ {contractName}
+
+);
+
+export default ContractTag;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.types.ts b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.types.ts
new file mode 100644
index 00000000000..082888ac193
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.types.ts
@@ -0,0 +1,3 @@
+export interface ContractTagProps {
+ contractName: string;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/ContractTag/__snapshots__/ContractTag.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/__snapshots__/ContractTag.test.tsx.snap
new file mode 100644
index 00000000000..bd7e17af2f8
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/ContractTag/__snapshots__/ContractTag.test.tsx.snap
@@ -0,0 +1,69 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ContractTag render matches snapshot 1`] = `
+
+
+
+
+
+ MM Pooled Staking
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.styles.ts
new file mode 100644
index 00000000000..33c5ad186e8
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.styles.ts
@@ -0,0 +1,39 @@
+import type { Theme } from '../../../../../../util/theme/models';
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ estGasFeeCard: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ justifyContent: 'center',
+ },
+ // Estimated Gas Fee
+ estGasFeeValue: {
+ flexDirection: 'row',
+ paddingTop: 1,
+ },
+ foxIcon: {
+ paddingRight: 8,
+ },
+ fiatText: {
+ paddingRight: 4,
+ },
+ ethText: {
+ borderBottomWidth: 1,
+ borderBottomColor: colors.primary.default,
+ },
+ estimatedGasTooltipContent: {
+ gap: 16,
+ },
+ gasLearnMoreLink: {
+ alignSelf: 'flex-start',
+ },
+ });
+};
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.test.tsx
new file mode 100644
index 00000000000..97a31da76f3
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.test.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import EstimatedGasCard from './EstimatedGasCard';
+import { strings } from '../../../../../../../locales/i18n';
+import { fireEvent } from '@testing-library/react-native';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+describe('EstimatedGasCard', () => {
+ it('render matches snapshot', () => {
+ const props = {
+ gasCostEth: '0.0884 ETH',
+ gasCostFiat: '$43.56',
+ };
+
+ const { getByText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(
+ getByText(strings('tooltip_modal.estimated_gas_fee.title')),
+ ).toBeDefined();
+ expect(getByText(props.gasCostEth)).toBeDefined();
+ expect(getByText(props.gasCostFiat)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('displays the estimated gas cost tooltip when pressed', () => {
+ const props = {
+ gasCostEth: '0.0884 ETH',
+ gasCostFiat: '$43.56',
+ };
+
+ const { getByLabelText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ fireEvent.press(
+ getByLabelText(
+ `${strings('tooltip_modal.estimated_gas_fee.title')} tooltip`,
+ ),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
+ params: {
+ title: strings('tooltip_modal.estimated_gas_fee.title'),
+ // difficult to directly compare ReactNodes
+ tooltip: expect.any(Object),
+ },
+ screen: 'tooltipModal',
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.tsx b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.tsx
new file mode 100644
index 00000000000..d8d9c83130e
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import { View, TouchableOpacity } from 'react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import KeyValueRow, {
+ TooltipSizes,
+} from '../../../../../../component-library/components-temp/KeyValueRow';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../../component-library/components/Texts/Text';
+import Card from '../../../../../../component-library/components/Cards/Card';
+import { useStyles } from '../../../../../hooks/useStyles';
+import useTooltipModal from '../../../../../hooks/useTooltipModal';
+import styleSheet from './EstimatedGasCard.styles';
+import { EstimatedGasCardProps } from './EstimatedGasCard.types';
+import EstimatedGasFeeTooltipContent from '../EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent';
+
+const EstimatedGasCard = ({
+ gasCostEth,
+ gasCostFiat,
+}: EstimatedGasCardProps) => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const { openTooltipModal } = useTooltipModal();
+
+ // TODO: Navigate to the edit gas bottom sheet
+ const handleNavigateToEditGas = () =>
+ openTooltipModal('TODO', 'Navigate to gas customization component');
+
+ return (
+
+ ,
+ size: TooltipSizes.Sm,
+ },
+ }}
+ value={{
+ label: (
+
+ 🦊
+
+ {gasCostFiat}
+
+
+
+
+ {gasCostEth}
+
+
+
+
+ ),
+ }}
+ />
+
+ );
+};
+
+export default EstimatedGasCard;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.types.ts b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.types.ts
new file mode 100644
index 00000000000..58cd5f1aed7
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.types.ts
@@ -0,0 +1,4 @@
+export interface EstimatedGasCardProps {
+ gasCostEth: string;
+ gasCostFiat: string;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/__snapshots__/EstimatedGasCard.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/__snapshots__/EstimatedGasCard.test.tsx.snap
new file mode 100644
index 00000000000..3bb66a34c5c
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/__snapshots__/EstimatedGasCard.test.tsx.snap
@@ -0,0 +1,419 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EstimatedGasCard displays the estimated gas cost tooltip when pressed 1`] = `
+
+
+
+
+
+
+ Estimated gas fee
+
+
+
+
+
+
+
+
+
+
+
+
+ 🦊
+
+
+ $43.56
+
+
+
+
+ 0.0884 ETH
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`EstimatedGasCard render matches snapshot 1`] = `
+
+
+
+
+
+
+ Estimated gas fee
+
+
+
+
+
+
+
+
+
+
+
+
+ 🦊
+
+
+ $43.56
+
+
+
+
+ 0.0884 ETH
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.styles.ts
new file mode 100644
index 00000000000..de81ef5a0bd
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.styles.ts
@@ -0,0 +1,13 @@
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = () =>
+ StyleSheet.create({
+ estimatedGasTooltipContent: {
+ gap: 16,
+ },
+ gasLearnMoreLink: {
+ alignSelf: 'flex-start',
+ },
+ });
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.test.tsx
new file mode 100644
index 00000000000..440fb7f7f3b
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.test.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import EstimatedGasFeeTooltipContent from './EstimatedGasFeeTooltipContent';
+import { strings } from '../../../../../../../locales/i18n';
+import { fireEvent } from '@testing-library/react-native';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+describe('EstimatedGasFeeTooltipContent', () => {
+ it('render matches snapshot', () => {
+ const { getByText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(
+ getByText(strings('tooltip_modal.estimated_gas_fee.gas_recipient')),
+ ).toBeDefined();
+ expect(
+ getByText(strings('tooltip_modal.estimated_gas_fee.gas_fluctuation')),
+ ).toBeDefined();
+ expect(
+ getByText(strings('tooltip_modal.estimated_gas_fee.gas_learn_more')),
+ ).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('redirects to "learn more about gas fees" web view when learn more pressed', () => {
+ const { getByText } = renderWithProvider();
+
+ fireEvent.press(
+ getByText(strings('tooltip_modal.estimated_gas_fee.gas_learn_more')),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('Webview', {
+ params: {
+ url: 'https://support.metamask.io/transactions-and-gas/gas-fees/why-are-my-gas-fees-so-high/',
+ },
+ screen: 'SimpleWebview',
+ });
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.tsx b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.tsx
new file mode 100644
index 00000000000..65a52044848
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { useNavigation } from '@react-navigation/native';
+import { View, TouchableOpacity } from 'react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../../component-library/components/Texts/Text';
+import AppConstants from '../../../../../../core/AppConstants';
+import { useStyles } from '../../../../../hooks/useStyles';
+import styleSheet from './EstimatedGasFeeTooltipContent.styles';
+
+export const EstimatedGasFeeTooltipContent = () => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const navigation = useNavigation();
+
+ const handleNavigateToGasLearnMore = () =>
+ navigation.navigate('Webview', {
+ screen: 'SimpleWebview',
+ params: {
+ url: AppConstants.REVIEW_PROMPT.HIGH_GAS_FEES,
+ },
+ });
+
+ return (
+
+ {strings('tooltip_modal.estimated_gas_fee.gas_recipient')}
+ {strings('tooltip_modal.estimated_gas_fee.gas_fluctuation')}
+
+
+ {strings('tooltip_modal.estimated_gas_fee.gas_learn_more')}
+
+
+
+ );
+};
+
+export default EstimatedGasFeeTooltipContent;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/__snapshots__/EstimatedGasFeeTooltipContent.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/__snapshots__/EstimatedGasFeeTooltipContent.test.tsx.snap
new file mode 100644
index 00000000000..bd50772cb00
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/__snapshots__/EstimatedGasFeeTooltipContent.test.tsx.snap
@@ -0,0 +1,67 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`EstimatedGasFeeTooltipContent render matches snapshot 1`] = `
+
+
+ Gas fees are paid to crypto miners who process transactions on Ethereum network. Metamask does not profit from gas fees.
+
+
+ Gas fees are estimated and will fluctuate based on network traffic and transaction complexity.
+
+
+
+ Learn more about gas fees
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.styles.ts
new file mode 100644
index 00000000000..85e2cd913df
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.styles.ts
@@ -0,0 +1,16 @@
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = () =>
+ StyleSheet.create({
+ card: {
+ borderWidth: 0,
+ gap: 16,
+ borderRadius: 8,
+ },
+ estAnnualRewardValue: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ });
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
new file mode 100644
index 00000000000..ae8bebfe465
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
@@ -0,0 +1,98 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import RewardsCard from './RewardsCard';
+import { RewardsCardProps } from './RewardsCard.types';
+import { fireEvent } from '@testing-library/react-native';
+import { strings } from '../../../../../../../locales/i18n';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+describe('RewardsCard', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('render matches snapshot', () => {
+ const props: RewardsCardProps = {
+ rewardRate: '2.6',
+ rewardsEth: '0.13 ETH',
+ rewardsFiat: '$334.93',
+ };
+
+ const { getByText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(getByText(`${props.rewardRate}%`)).toBeDefined();
+ expect(getByText(props.rewardsEth)).toBeDefined();
+ expect(getByText(props.rewardsFiat)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('reward rate tooltip displayed when pressed', () => {
+ const props: RewardsCardProps = {
+ rewardRate: '2.6',
+ rewardsEth: '0.13 ETH',
+ rewardsFiat: '$334.93',
+ };
+
+ const { toJSON, getByLabelText } = renderWithProvider(
+ ,
+ );
+
+ fireEvent.press(
+ getByLabelText(`${strings('tooltip_modal.reward_rate.title')} tooltip`),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
+ params: {
+ title: strings('tooltip_modal.reward_rate.title'),
+ tooltip: strings('tooltip_modal.reward_rate.tooltip'),
+ },
+ screen: 'tooltipModal',
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+
+ it('reward frequency tooltip displayed when pressed', () => {
+ const props: RewardsCardProps = {
+ rewardRate: '2.6',
+ rewardsEth: '0.13 ETH',
+ rewardsFiat: '$334.93',
+ };
+
+ const { toJSON, getByLabelText } = renderWithProvider(
+ ,
+ );
+
+ fireEvent.press(
+ getByLabelText(
+ `${strings('tooltip_modal.reward_frequency.title')} tooltip`,
+ ),
+ );
+
+ expect(mockNavigate).toHaveBeenCalledTimes(1);
+ expect(mockNavigate).toHaveBeenCalledWith('RootModalFlow', {
+ params: {
+ title: strings('tooltip_modal.reward_frequency.title'),
+ tooltip: strings('tooltip_modal.reward_frequency.tooltip'),
+ },
+ screen: 'tooltipModal',
+ });
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
new file mode 100644
index 00000000000..66b6eacedf5
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
@@ -0,0 +1,74 @@
+import React from 'react';
+import { View } from 'react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import KeyValueRow, {
+ TooltipSizes,
+} from '../../../../../../component-library/components-temp/KeyValueRow';
+import Text, {
+ TextColor,
+ TextVariant,
+} from '../../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../hooks/useStyles';
+import Card from '../../../../../../component-library/components/Cards/Card';
+import styleSheet from './RewardsCard.styles';
+import { RewardsCardProps } from './RewardsCard.types';
+import { fixDisplayAmount } from '../../../utils/value';
+
+const RewardsCard = ({
+ rewardRate,
+ rewardsEth,
+ rewardsFiat,
+}: RewardsCardProps) => {
+ const { styles } = useStyles(styleSheet, {});
+
+ return (
+
+
+
+ {rewardsFiat}
+ {rewardsEth}
+
+ ),
+ }}
+ />
+
+
+ );
+};
+
+export default RewardsCard;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.types.ts b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.types.ts
new file mode 100644
index 00000000000..3d60d99f371
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.types.ts
@@ -0,0 +1,5 @@
+export interface RewardsCardProps {
+ rewardRate: string;
+ rewardsEth: string;
+ rewardsFiat: string;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/__snapshots__/RewardsCard.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/__snapshots__/RewardsCard.test.tsx.snap
new file mode 100644
index 00000000000..e2ace2593d4
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/RewardsCard/__snapshots__/RewardsCard.test.tsx.snap
@@ -0,0 +1,1255 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`RewardsCard render matches snapshot 1`] = `
+
+
+
+
+
+
+ Reward rate
+
+
+
+
+
+
+
+
+
+
+
+ 2.6%
+
+
+
+
+
+
+
+
+
+
+ Estimated annual rewards
+
+
+
+
+
+
+
+
+
+ $334.93
+
+
+ 0.13 ETH
+
+
+
+
+
+
+
+
+
+
+
+ Reward frequency
+
+
+
+
+
+
+
+
+
+
+
+ 12 hours
+
+
+
+
+
+
+`;
+
+exports[`RewardsCard reward frequency tooltip displayed when pressed 1`] = `
+
+
+
+
+
+
+ Reward rate
+
+
+
+
+
+
+
+
+
+
+
+ 2.6%
+
+
+
+
+
+
+
+
+
+
+ Estimated annual rewards
+
+
+
+
+
+
+
+
+
+ $334.93
+
+
+ 0.13 ETH
+
+
+
+
+
+
+
+
+
+
+
+ Reward frequency
+
+
+
+
+
+
+
+
+
+
+
+ 12 hours
+
+
+
+
+
+
+`;
+
+exports[`RewardsCard reward rate tooltip displayed when pressed 1`] = `
+
+
+
+
+
+
+ Reward rate
+
+
+
+
+
+
+
+
+
+
+
+ 2.6%
+
+
+
+
+
+
+
+
+
+
+ Estimated annual rewards
+
+
+
+
+
+
+
+
+
+ $334.93
+
+
+ 0.13 ETH
+
+
+
+
+
+
+
+
+
+
+
+ Reward frequency
+
+
+
+
+
+
+
+
+
+
+
+ 12 hours
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.styles.ts b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.styles.ts
new file mode 100644
index 00000000000..36e6e22593b
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.styles.ts
@@ -0,0 +1,23 @@
+import { StyleSheet } from 'react-native';
+
+const stylesSheet = () =>
+ StyleSheet.create({
+ tokenValueStackContainer: {
+ alignItems: 'center',
+ paddingVertical: 8,
+ gap: 8,
+ },
+ badgeWrapper: {
+ alignSelf: 'center',
+ },
+ ethLogo: {
+ width: 48,
+ height: 48,
+ borderRadius: 24,
+ },
+ balancesContainer: {
+ alignItems: 'center',
+ },
+ });
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx
new file mode 100644
index 00000000000..bcc0060c555
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import TokenValueStack from './TokenValueStack';
+import { Image } from 'react-native';
+import { TokenValueStackProps } from './TokenValueStack.types';
+import { renderFromWei } from '../../../../../../util/number';
+
+jest.mock('../../../../../hooks/useIpfsGateway', () => jest.fn());
+
+Image.getSize = jest.fn((_uri, success) => {
+ success(100, 100); // Mock successful response for ETH native Icon Image
+});
+
+describe('TokenValueStack', () => {
+ it('render matches snapshot', () => {
+ const props: TokenValueStackProps = {
+ amountWei: '3210000000000000',
+ amountFiat: '7.46',
+ tokenSymbol: 'wETH',
+ };
+
+ const { getByText, toJSON } = renderWithProvider(
+ ,
+ );
+
+ expect(
+ getByText(`${renderFromWei(props.amountWei)} ${props.tokenSymbol}`),
+ ).toBeDefined(); // 0.00321 wETH
+ expect(getByText(props.amountFiat)).toBeDefined();
+
+ expect(toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx
new file mode 100644
index 00000000000..8d98a2a05fb
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+import { View } from 'react-native';
+import { useSelector } from 'react-redux';
+import Badge, {
+ BadgeVariant,
+} from '../../../../../../component-library/components/Badges/Badge';
+import BadgeWrapper from '../../../../../../component-library/components/Badges/BadgeWrapper';
+import Text, {
+ TextVariant,
+ TextColor,
+} from '../../../../../../component-library/components/Texts/Text';
+import { selectNetworkName } from '../../../../../../selectors/networkInfos';
+import { useStyles } from '../../../../../hooks/useStyles';
+import NetworkMainAssetLogo from '../../../../NetworkMainAssetLogo';
+import styleSheet from './TokenValueStack.styles';
+import images from '../../../../../../images/image-icons';
+import { TokenValueStackProps } from './TokenValueStack.types';
+import { renderFromWei } from '../../../../../../util/number';
+
+const TokenValueStack = ({
+ amountWei,
+ amountFiat,
+ tokenSymbol,
+ style,
+}: TokenValueStackProps) => {
+ const { styles } = useStyles(styleSheet, {});
+
+ const networkName = useSelector(selectNetworkName);
+
+ return (
+
+
+ }
+ >
+
+
+
+
+ {renderFromWei(amountWei)} {tokenSymbol}
+
+ {amountFiat}
+
+
+ );
+};
+
+export default TokenValueStack;
diff --git a/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.types.ts b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.types.ts
new file mode 100644
index 00000000000..add4a7ba379
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.types.ts
@@ -0,0 +1,7 @@
+import { ViewProps } from 'react-native';
+
+export interface TokenValueStackProps extends Pick {
+ amountWei: string;
+ amountFiat: string;
+ tokenSymbol: string;
+}
diff --git a/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/__snapshots__/TokenValueStack.test.tsx.snap b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/__snapshots__/TokenValueStack.test.tsx.snap
new file mode 100644
index 00000000000..32f3cf0ccfc
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/__snapshots__/TokenValueStack.test.tsx.snap
@@ -0,0 +1,212 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TokenValueStack render matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 0.00321
+
+ wETH
+
+
+ 7.46
+
+
+
+`;
diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx
index 321d0eb7f1c..14993705ca5 100644
--- a/app/components/UI/Stake/routes/index.tsx
+++ b/app/components/UI/Stake/routes/index.tsx
@@ -3,6 +3,7 @@ import { createStackNavigator } from '@react-navigation/stack';
import StakeInputView from '../Views/StakeInputView/StakeInputView';
import LearnMoreModal from '../components/LearnMoreModal';
import Routes from '../../../../constants/navigation/Routes';
+import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmationView';
import UnstakeInputView from '../Views/UnstakeInputView/UnstakeInputView';
const Stack = createStackNavigator();
const ModalStack = createStackNavigator();
@@ -20,6 +21,10 @@ const StakeScreenStack = () => (
+
);
diff --git a/app/components/Views/TooltipModal/ToolTipModal.styles.ts b/app/components/Views/TooltipModal/ToolTipModal.styles.ts
index ff89c01b839..15b9645e681 100644
--- a/app/components/Views/TooltipModal/ToolTipModal.styles.ts
+++ b/app/components/Views/TooltipModal/ToolTipModal.styles.ts
@@ -3,7 +3,6 @@ import { StyleSheet } from 'react-native';
const styleSheet = () =>
StyleSheet.create({
content: {
- paddingBottom: 16,
paddingHorizontal: 32,
flexDirection: 'row',
justifyContent: 'center',
diff --git a/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/AccountNetworkInfoExpanded.tsx b/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/AccountNetworkInfoExpanded.tsx
index a4750a7f191..860b145103e 100644
--- a/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/AccountNetworkInfoExpanded.tsx
+++ b/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/AccountNetworkInfoExpanded.tsx
@@ -9,6 +9,7 @@ import useAccountInfo from '../../../../hooks/useAccountInfo';
import useApprovalRequest from '../../../../hooks/useApprovalRequest';
import InfoSection from '../../../UI/InfoRow/InfoSection';
import InfoRow from '../../../UI/InfoRow';
+import Address from '../../../UI/InfoRow/InfoValue/Address';
import InfoURL from '../../../UI/InfoRow/InfoValue/InfoURL';
// todo: use value component for address, network, currency value
@@ -23,7 +24,9 @@ const AccountNetworkInfoExpanded = () => {
return (
- {accountAddress}
+
+
+
{accountBalance}
diff --git a/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/__snapshots__/AccountNetworkInfoExpanded.test.tsx.snap b/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/__snapshots__/AccountNetworkInfoExpanded.test.tsx.snap
index cae8ebe4e32..212fc75ed78 100644
--- a/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/__snapshots__/AccountNetworkInfoExpanded.test.tsx.snap
+++ b/app/components/Views/confirmations/components/Confirm/AccountNetworkInfo/AccountNetworkInfoExpanded/__snapshots__/AccountNetworkInfoExpanded.test.tsx.snap
@@ -49,19 +49,52 @@ exports[`AccountNetworkInfoExpanded should match snapshot for personal sign 1`]
Account
-
- 0x935E73EDb9fF52E23BaC7F7e043A1ecD06d05477
-
+
+
+ 0x935E7...05477
+
+
{
+ it('should match snapshot', async () => {
+ const container = renderWithProvider(, {
+ state: mockInitialState,
+ });
+ expect(container).toMatchSnapshot();
+ });
+});
diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/Address.tsx b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/Address.tsx
new file mode 100644
index 00000000000..2cb85054c7b
--- /dev/null
+++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/Address.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import Name from '../../../../../../../UI/Name';
+import { NameType } from '../../../../../../../UI/Name/Name.types';
+
+interface AddressProps {
+ address: string;
+}
+
+const Address = ({ address }: AddressProps) => (
+
+);
+
+export default Address;
diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap
new file mode 100644
index 00000000000..c0a8749fcc1
--- /dev/null
+++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap
@@ -0,0 +1,50 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`InfoAddress should match snapshot 1`] = `
+
+
+
+ 0xC4955...4D272
+
+
+`;
diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/index.ts b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/index.ts
new file mode 100644
index 00000000000..189acc8b2c5
--- /dev/null
+++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/index.ts
@@ -0,0 +1 @@
+export { default } from './Address';
diff --git a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/InfoURL/InfoURL.test.tsx b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/InfoURL/InfoURL.test.tsx
index 1522f6dcd23..88bcc940b85 100644
--- a/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/InfoURL/InfoURL.test.tsx
+++ b/app/components/Views/confirmations/components/UI/InfoRow/InfoValue/InfoURL/InfoURL.test.tsx
@@ -1,7 +1,7 @@
import React from 'react';
import { render } from '@testing-library/react-native';
-import InfoURL from './index';
+import InfoURL from './InfoURL';
describe('InfoURL', () => {
it('should display url as expected', async () => {
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index 0becb6358b7..6bccce37031 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -144,6 +144,7 @@ const Routes = {
},
STAKING: {
STAKE: 'Stake',
+ STAKE_CONFIRMATION: 'StakeConfirmation',
UNSTAKE: 'Unstake',
CLAIM: 'Claim',
MODALS: {
diff --git a/app/core/AppConstants.ts b/app/core/AppConstants.ts
index 4999b670957..8aea9a7cb72 100644
--- a/app/core/AppConstants.ts
+++ b/app/core/AppConstants.ts
@@ -129,6 +129,7 @@ export default {
'https://support.metamask.io/privacy-and-security/privacy-best-practices',
SMART_TXS:
'https://support.metamask.io/transactions-and-gas/transactions/smart-transactions/',
+ STAKING_RISK_DISCLOSURE: 'https://consensys.io/staking-risk-disclosures',
},
ERRORS: {
INFURA_BLOCKED_MESSAGE:
diff --git a/app/selectors/nftController.ts b/app/selectors/nftController.ts
index a2a3a669074..b6a217f727a 100644
--- a/app/selectors/nftController.ts
+++ b/app/selectors/nftController.ts
@@ -1,5 +1,5 @@
import { createSelector } from 'reselect';
-import { NftControllerState } from '@metamask/assets-controllers';
+import { Nft, NftControllerState } from '@metamask/assets-controllers';
import { RootState } from '../reducers';
const selectNftControllerState = (state: RootState) =>
@@ -15,3 +15,14 @@ export const selectAllNfts = createSelector(
selectNftControllerState,
(nftControllerState: NftControllerState) => nftControllerState.allNfts,
);
+
+export const selectAllNftsFlat = createSelector(
+ selectAllNfts,
+ (nftsByChainByAccount) => {
+ const nftsByChainArray = Object.values(nftsByChainByAccount);
+ return nftsByChainArray.reduce((acc, nftsByChain) => {
+ const nftsArrays = Object.values(nftsByChain);
+ return acc.concat(...nftsArrays);
+ }, [] as Nft[]);
+ },
+);
diff --git a/app/selectors/tokensController.ts b/app/selectors/tokensController.ts
index f63f4c857a6..ccebe64ebfb 100644
--- a/app/selectors/tokensController.ts
+++ b/app/selectors/tokensController.ts
@@ -37,3 +37,24 @@ export const selectDetectedTokens = createSelector(
(tokensControllerState: TokensControllerState) =>
tokensControllerState?.detectedTokens,
);
+
+const selectAllTokens = createSelector(
+ selectTokensControllerState,
+ (tokensControllerState: TokensControllerState) =>
+ tokensControllerState?.allTokens,
+);
+
+export const selectAllTokensFlat = createSelector(
+ selectAllTokens,
+ (tokensByAccountByChain) => {
+ if (Object.values(tokensByAccountByChain).length === 0) {
+ return [];
+ }
+ const tokensByAccountArray = Object.values(tokensByAccountByChain);
+
+ return tokensByAccountArray.reduce((acc, tokensByAccount) => {
+ const tokensArray = Object.values(tokensByAccount);
+ return acc.concat(...tokensArray);
+ }, [] as Token[]);
+ },
+);
diff --git a/app/store/index.ts b/app/store/index.ts
index 84a500f8784..9cc858e9ac0 100644
--- a/app/store/index.ts
+++ b/app/store/index.ts
@@ -16,6 +16,7 @@ import thunk from 'redux-thunk';
import persistConfig from './persistConfig';
import { AppStateEventProcessor } from '../core/AppStateEventListener';
+import { getTraceTags } from '../util/sentry/tags';
// TODO: Improve type safety by using real Action types instead of `any`
// TODO: Replace "any" with type
@@ -119,6 +120,7 @@ const createStoreAndPersistor = async (appStartTime: number) => {
{
name: TraceName.EngineInitialization,
op: TraceOperation.EngineInitialization,
+ tags: getTraceTags(store.getState?.()),
},
() => {
EngineService.initalizeEngine(store);
diff --git a/app/util/sentry/tags/index.test.ts b/app/util/sentry/tags/index.test.ts
new file mode 100644
index 00000000000..5511ab7cf92
--- /dev/null
+++ b/app/util/sentry/tags/index.test.ts
@@ -0,0 +1,229 @@
+import { RootState } from '../../../reducers';
+import { getTraceTags } from './';
+import initialRootState, {
+ backgroundState,
+} from '../../../util/test/initial-root-state';
+import { userInitialState } from '../../../reducers/user';
+import { createMockAccountsControllerState } from '../../../util/test/accountsControllerTestUtils';
+
+describe('Tags Utils', () => {
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('getTraceTags', () => {
+ it('includes if unlocked', () => {
+ const state = {
+ ...initialRootState,
+ user: { ...userInitialState, userLoggedIn: true },
+ };
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.unlocked']).toStrictEqual(true);
+ });
+
+ it('includes if not unlocked', () => {
+ const state = {
+ ...initialRootState,
+ user: { ...userInitialState, userLoggedIn: false },
+ };
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.unlocked']).toStrictEqual(false);
+ });
+
+ it('includes pending approval type', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ ApprovalController: {
+ ...backgroundState.ApprovalController,
+ pendingApprovals: {
+ 1: {
+ type: 'eth_sendTransaction',
+ },
+ },
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.pending_approval']).toStrictEqual(
+ 'eth_sendTransaction',
+ );
+ });
+
+ it('includes first pending approval type if multiple', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+
+ ApprovalController: {
+ ...backgroundState.ApprovalController,
+ pendingApprovals: {
+ 1: {
+ type: 'eth_sendTransaction',
+ },
+ 2: {
+ type: 'personal_sign',
+ },
+ },
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.pending_approval']).toStrictEqual(
+ 'eth_sendTransaction',
+ );
+ });
+
+ it('includes account count', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: createMockAccountsControllerState([
+ '0x1234',
+ '0x4321',
+ ]),
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.account_count']).toStrictEqual(2);
+ });
+
+ it('includes nft count', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ NftController: {
+ ...backgroundState.NftController,
+ allNfts: {
+ '0x1234': {
+ '0x1': [
+ {
+ tokenId: '1',
+ },
+ {
+ tokenId: '2',
+ },
+ ],
+ '0x2': [
+ {
+ tokenId: '3',
+ },
+ {
+ tokenId: '4',
+ },
+ ],
+ },
+ '0x4321': {
+ '0x3': [
+ {
+ tokenId: '5',
+ },
+ ],
+ },
+ },
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.nft_count']).toStrictEqual(5);
+ });
+
+ it('includes notification count', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ NotificationServicesController: {
+ metamaskNotificationsList: [{}, {}, {}],
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.notification_count']).toStrictEqual(3);
+ });
+
+ it('includes token count', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ TokensController: {
+ allTokens: {
+ '0x1': {
+ '0x1234': [{}, {}],
+ '0x4321': [{}],
+ },
+ '0x2': {
+ '0x5678': [{}],
+ },
+ },
+ },
+ },
+ },
+ } as unknown as RootState;
+
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.token_count']).toStrictEqual(4);
+ });
+
+ it('includes transaction count', () => {
+ const state = {
+ ...initialRootState,
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ TransactionController: {
+ transactions: [
+ {
+ id: 1,
+ chainId: '0x1',
+ },
+ {
+ id: 2,
+ chainId: '0x1',
+ },
+ {
+ id: 3,
+ chainId: '0x2',
+ },
+ ],
+ },
+ },
+ },
+ } as unknown as RootState;
+ const tags = getTraceTags(state);
+
+ expect(tags?.['wallet.transaction_count']).toStrictEqual(3);
+ });
+ });
+});
diff --git a/app/util/sentry/tags/index.ts b/app/util/sentry/tags/index.ts
new file mode 100644
index 00000000000..796bb9212fe
--- /dev/null
+++ b/app/util/sentry/tags/index.ts
@@ -0,0 +1,30 @@
+import { RootState } from '../../../reducers';
+import { selectAllNftsFlat } from '../../../selectors/nftController';
+import { selectInternalAccounts } from '../../../selectors/accountsController';
+import { selectAllTokensFlat } from '../../../selectors/tokensController';
+import { getNotificationsList } from '../../../selectors/notifications';
+import { selectTransactions } from '../../../selectors/transactionController';
+import { selectPendingApprovals } from '../../../selectors/approvalController';
+
+export function getTraceTags(state: RootState) {
+ if (!Object.keys(state?.engine?.backgroundState).length) return;
+ const unlocked = state.user.userLoggedIn;
+ const accountCount = selectInternalAccounts(state).length;
+ const nftCount = selectAllNftsFlat(state).length;
+ const notificationCount = getNotificationsList(state).length;
+ const tokenCount = selectAllTokensFlat(state).length;
+ const transactionCount = selectTransactions(state).length;
+ const pendingApprovals = Object.values(selectPendingApprovals(state));
+
+ const firstApprovalType = pendingApprovals?.[0]?.type;
+
+ return {
+ 'wallet.account_count': accountCount,
+ 'wallet.nft_count': nftCount,
+ 'wallet.notification_count': notificationCount,
+ 'wallet.pending_approval': firstApprovalType,
+ 'wallet.token_count': tokenCount,
+ 'wallet.transaction_count': transactionCount,
+ 'wallet.unlocked': unlocked,
+ };
+}
diff --git a/app/util/trace.test.ts b/app/util/trace.test.ts
index cb90722c9cb..b9e541ebdc5 100644
--- a/app/util/trace.test.ts
+++ b/app/util/trace.test.ts
@@ -1,17 +1,19 @@
-import { startSpan, startSpanManual, withScope } from '@sentry/react-native';
+import {
+ Scope,
+ setMeasurement,
+ startSpan,
+ startSpanManual,
+ withScope,
+} from '@sentry/react-native';
import { Span } from '@sentry/types';
-import {
- endTrace,
- trace,
- TraceName,
- TRACES_CLEANUP_INTERVAL,
-} from './trace';
+import { endTrace, trace, TraceName, TRACES_CLEANUP_INTERVAL } from './trace';
jest.mock('@sentry/react-native', () => ({
withScope: jest.fn(),
startSpan: jest.fn(),
startSpanManual: jest.fn(),
+ setMeasurement: jest.fn(),
}));
const NAME_MOCK = TraceName.Middleware;
@@ -36,15 +38,23 @@ describe('Trace', () => {
const startSpanMock = jest.mocked(startSpan);
const startSpanManualMock = jest.mocked(startSpanManual);
const withScopeMock = jest.mocked(withScope);
- const setTagsMock = jest.fn();
+ const setMeasurementMock = jest.mocked(setMeasurement);
+ const setTagMock = jest.fn();
beforeEach(() => {
jest.resetAllMocks();
startSpanMock.mockImplementation((_, fn) => fn({} as Span));
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- withScopeMock.mockImplementation((fn: any) => fn({ setTags: setTagsMock }));
+ startSpanManualMock.mockImplementation((_, fn) =>
+ fn({} as Span, () => {
+ // Intentionally empty
+ }),
+ );
+
+ withScopeMock.mockImplementation((fn: (arg: Scope) => unknown) =>
+ fn({ setTag: setTagMock } as unknown as Scope),
+ );
});
describe('trace', () => {
@@ -87,8 +97,12 @@ describe('Trace', () => {
expect.any(Function),
);
- expect(setTagsMock).toHaveBeenCalledTimes(1);
- expect(setTagsMock).toHaveBeenCalledWith(TAGS_MOCK);
+ expect(setTagMock).toHaveBeenCalledTimes(2);
+ expect(setTagMock).toHaveBeenCalledWith('tag1', 'value1');
+ expect(setTagMock).toHaveBeenCalledWith('tag2', true);
+
+ expect(setMeasurementMock).toHaveBeenCalledTimes(1);
+ expect(setMeasurementMock).toHaveBeenCalledWith('tag3', 123, 'none');
});
it('invokes Sentry if no callback provided', () => {
@@ -113,8 +127,12 @@ describe('Trace', () => {
expect.any(Function),
);
- expect(setTagsMock).toHaveBeenCalledTimes(1);
- expect(setTagsMock).toHaveBeenCalledWith(TAGS_MOCK);
+ expect(setTagMock).toHaveBeenCalledTimes(2);
+ expect(setTagMock).toHaveBeenCalledWith('tag1', 'value1');
+ expect(setTagMock).toHaveBeenCalledWith('tag2', true);
+
+ expect(setMeasurementMock).toHaveBeenCalledTimes(1);
+ expect(setMeasurementMock).toHaveBeenCalledWith('tag3', 123, 'none');
});
it('invokes Sentry if no callback provided with custom start time', () => {
@@ -141,8 +159,12 @@ describe('Trace', () => {
expect.any(Function),
);
- expect(setTagsMock).toHaveBeenCalledTimes(1);
- expect(setTagsMock).toHaveBeenCalledWith(TAGS_MOCK);
+ expect(setTagMock).toHaveBeenCalledTimes(2);
+ expect(setTagMock).toHaveBeenCalledWith('tag1', 'value1');
+ expect(setTagMock).toHaveBeenCalledWith('tag2', true);
+
+ expect(setMeasurementMock).toHaveBeenCalledTimes(1);
+ expect(setMeasurementMock).toHaveBeenCalledWith('tag3', 123, 'none');
});
});
diff --git a/app/util/trace.ts b/app/util/trace.ts
index fd6b4c9dfb3..05ad23d9dd8 100644
--- a/app/util/trace.ts
+++ b/app/util/trace.ts
@@ -2,15 +2,19 @@ import {
startSpan as sentryStartSpan,
startSpanManual,
withScope,
+ setMeasurement,
+ Scope,
} from '@sentry/react-native';
import performance from 'react-native-performance';
-import type { Primitive, Span, StartSpanOptions } from '@sentry/types';
+import type { Span, StartSpanOptions, MeasurementUnit } from '@sentry/types';
import { createModuleLogger, createProjectLogger } from '@metamask/utils';
// Cannot create this 'sentry' logger in Sentry util file because of circular dependency
const projectLogger = createProjectLogger('sentry');
const log = createModuleLogger(projectLogger, 'trace');
-
+/**
+ * The supported trace names.
+ */
export enum TraceName {
DeveloperTest = 'Developer Test',
Middleware = 'Middleware',
@@ -56,24 +60,72 @@ export interface PendingTrace {
startTime: number;
timeoutId: NodeJS.Timeout;
}
-
+/**
+ * A context object to associate traces with each other and generate nested traces.
+ */
export type TraceContext = unknown;
-
+/**
+ * A callback function that can be traced.
+ */
export type TraceCallback = (context?: TraceContext) => T;
-
+/**
+ * A request to create a new trace.
+ */
export interface TraceRequest {
+ /**
+ * Custom data to associate with the trace.
+ */
data?: Record;
+
+ /**
+ * A unique identifier when not tracing a callback.
+ * Defaults to 'default' if not provided.
+ */
id?: string;
+
+ /**
+ * The name of the trace.
+ */
name: TraceName;
+
+ /**
+ * The parent context of the trace.
+ * If provided, the trace will be nested under the parent trace.
+ */
parentContext?: TraceContext;
+
+ /**
+ * Override the start time of the trace.
+ */
startTime?: number;
+
+ /**
+ * Custom tags to associate with the trace.
+ */
tags?: Record;
+ /**
+ * Custom operation name to associate with the trace.
+ */
op?: string;
}
-
+/**
+ * A request to end a pending trace.
+ */
export interface EndTraceRequest {
+ /**
+ * The unique identifier of the trace.
+ * Defaults to 'default' if not provided.
+ */
id?: string;
+
+ /**
+ * The name of the trace.
+ */
name: TraceName;
+
+ /**
+ * Override the end time of the trace.
+ */
timestamp?: number;
}
@@ -81,6 +133,16 @@ export function trace(request: TraceRequest, fn: TraceCallback): T;
export function trace(request: TraceRequest): TraceContext;
+/**
+ * Create a Sentry transaction to analyse the duration of a code flow.
+ * If a callback is provided, the transaction will be automatically ended when the callback completes.
+ * If the callback returns a promise, the transaction will be ended when the promise resolves or rejects.
+ * If no callback is provided, the transaction must be manually ended using `endTrace`.
+ *
+ * @param request - The data associated with the trace, such as the name and tags.
+ * @param fn - The optional callback to record the duration of.
+ * @returns The context of the trace, or the result of the callback if provided.
+ */
export function trace(
request: TraceRequest,
fn?: TraceCallback,
@@ -92,6 +154,12 @@ export function trace(
return traceCallback(request, fn);
}
+/**
+ * End a pending trace that was started without a callback.
+ * Does nothing if the pending trace cannot be found.
+ *
+ * @param request - The data necessary to identify and end the pending trace.
+ */
export function endTrace(request: EndTraceRequest) {
const { name, timestamp } = request;
const id = getTraceId(request);
@@ -124,6 +192,10 @@ function traceCallback(request: TraceRequest, fn: TraceCallback): T {
const start = Date.now();
let error: unknown;
+ if (span) {
+ initSpan(span, request);
+ }
+
return tryCatchMaybePromise(
() => fn(span),
(currentError) => {
@@ -154,6 +226,10 @@ function startTrace(request: TraceRequest): TraceContext {
span?.end(timestamp);
};
+ if (span) {
+ initSpan(span, request);
+ }
+
const timeoutId = setTimeout(() => {
log('Trace cleanup due to timeout', name, id);
end();
@@ -178,14 +254,7 @@ function startSpan(
request: TraceRequest,
callback: (spanOptions: StartSpanOptions) => T,
) {
- const {
- data: attributes,
- name,
- parentContext,
- startTime,
- tags,
- op,
- } = request;
+ const { data: attributes, name, parentContext, startTime, op } = request;
const parentSpan = (parentContext ?? null) as Span | null;
const spanOptions: StartSpanOptions = {
@@ -199,7 +268,7 @@ function startSpan(
};
return withScope((scope) => {
- scope.setTags(tags as Record);
+ initScope(scope, request);
return callback(spanOptions);
}) as T;
@@ -216,6 +285,40 @@ function getTraceKey(request: TraceRequest) {
return [name, id].join(':');
}
+/**
+ * Initialise the isolated Sentry scope created for each trace.
+ * Includes setting all non-numeric tags.
+ *
+ * @param scope - The Sentry scope to initialise.
+ * @param request - The trace request.
+ */
+function initScope(scope: Scope, request: TraceRequest) {
+ const tags = request.tags ?? {};
+
+ for (const [key, value] of Object.entries(tags)) {
+ if (typeof value !== 'number') {
+ scope.setTag(key, value);
+ }
+ }
+}
+
+/**
+ * Initialise the Sentry span created for each trace.
+ * Includes setting all numeric tags as measurements so they can be queried numerically in Sentry.
+ *
+ * @param _span - The Sentry span to initialise.
+ * @param request - The trace request.
+ */
+function initSpan(_span: Span, request: TraceRequest) {
+ const tags = request.tags ?? {};
+
+ for (const [key, value] of Object.entries(tags)) {
+ if (typeof value === 'number') {
+ sentrySetMeasurement(key, value, 'none');
+ }
+ }
+}
+
function getPerformanceTimestamp(): number {
return performance.timeOrigin + performance.now();
}
@@ -248,3 +351,11 @@ function tryCatchMaybePromise(
return undefined;
}
+
+function sentrySetMeasurement(
+ key: string,
+ value: number,
+ unit: MeasurementUnit,
+) {
+ setMeasurement(key, value, unit);
+}
diff --git a/e2e/fixtures/fixture-builder.js b/e2e/fixtures/fixture-builder.js
index c4e987b3f01..b850fab5970 100644
--- a/e2e/fixtures/fixture-builder.js
+++ b/e2e/fixtures/fixture-builder.js
@@ -421,6 +421,18 @@ class FixtureBuilder {
pendingApprovalCount: 0,
approvalFlows: [],
},
+ NotificationServicesController: {
+ subscriptionAccountsSeen: [],
+ isMetamaskNotificationsFeatureSeen: false,
+ isNotificationServicesEnabled: false,
+ isFeatureAnnouncementsEnabled: false,
+ metamaskNotificationsList: [],
+ metamaskNotificationsReadList: [],
+ isUpdatingMetamaskNotifications: false,
+ isFetchingMetamaskNotifications: false,
+ isUpdatingMetamaskNotificationsAccount: [],
+ isCheckingAccountsPresence: false,
+ },
},
},
privacy: {
diff --git a/locales/languages/en.json b/locales/languages/en.json
index e4c5e637b3e..e2bbea8e5c3 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3351,7 +3351,14 @@
"approximately": "approximately"
},
"unstake_input_banner_description":"On average, it takes less than 3 days for the unstaked ETH to be claimable, but can take up to 11 days.",
- "max": "Max"
+ "max": "Max",
+ "staking_from": "Staking from",
+ "interacting_with": "Interacting with",
+ "12_hours": "12 hours",
+ "terms_of_service": "Terms of service",
+ "risk_disclosure": "Risk disclosure",
+ "cancel": "Cancel",
+ "confirm": "Confirm"
},
"default_settings": {
"title": "Your Wallet is ready",
@@ -3416,6 +3423,16 @@
"reward_rate": {
"title": "Reward rate",
"tooltip": "Expected yearly increase in the value of your stake, based on the reward rate over the past week."
+ },
+ "estimated_gas_fee": {
+ "title": "Estimated gas fee",
+ "gas_recipient": "Gas fees are paid to crypto miners who process transactions on Ethereum network. Metamask does not profit from gas fees.",
+ "gas_fluctuation": "Gas fees are estimated and will fluctuate based on network traffic and transaction complexity.",
+ "gas_learn_more": "Learn more about gas fees"
+ },
+ "reward_frequency": {
+ "title": "Reward frequency",
+ "tooltip": "Your staked balance updates every 12 hours to account for new rewards."
}
},
"confirm": {