From f996de194b885948f1706a25ace5d293798375fe Mon Sep 17 00:00:00 2001
From: Matthew Grainger <46547583+Matt561@users.noreply.github.com>
Date: Tue, 15 Oct 2024 20:05:05 -0400
Subject: [PATCH 1/4] feat: STAKE-824: [FE] build staking input confirmation
screen (#11605)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
This PR adds the staking confirmation screen with some mock data being
used temporarily.
### Change List
- Add staking confirmation screen.
- Connect existing `` to staking confirmation screen
when user enters valid amount to stake.
## **Related issues**
Ticket: [FE] Build staking input confirmation screen -
([link](https://consensyssoftware.atlassian.net/browse/STAKE-824))
Figma Designs -
[link](https://www.figma.com/design/1c0Y9jDJe6p0j82jydJDcs/Mobile-Staking?node-id=2979-22435&m=dev)
## **Manual testing steps**
1. Add `export MM_POOLED_STAKING_UI_ENABLED=true` to your `.js.env`
file.
2. Click on Ethereum In the token list page
3. Scroll down a bit and click "Stake more". This should open the stake
input view (not related to this PR)
4. Enter a valid amount to stake and click "Confirm"
5. You should be redirected to a staking confirmation screen. The screen
should display the amount to stake in `wETH` and Fiat.
## **Screenshots/Recordings**
### **Before**
Nothing would happen after clicking "Confirm" on the stake input view.
This screen is new.
### **After**
https://github.com/user-attachments/assets/84ea4c52-50c5-48c3-8077-2c2e8a92bf21
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
app/components/UI/Navbar/index.js | 63 +-
.../StakeConfirmationView.styles.ts | 23 +
.../StakeConfirmationView.test.tsx | 74 +
.../StakeConfirmationView.tsx | 60 +
.../StakeConfirmationView.types.ts | 10 +
.../StakeConfirmationView.test.tsx.snap | 1424 +++++++++++++++++
.../Views/StakeInputView/StakeInputView.tsx | 16 +-
.../StakeInputView.test.tsx.snap | 100 +-
.../UnstakeInputView/UnstakeInputView.tsx | 6 +-
.../UnstakeInputView.test.tsx.snap | 100 +-
.../StakingBalance/StakingBalance.test.tsx | 40 +-
.../StakingBalance/StakingCta/StakingCta.tsx | 6 +-
.../__snapshots__/StakingCta.test.tsx.snap | 2 +-
.../StakingBalance.test.tsx.snap | 1385 +++++++---------
.../AccountHeaderCard.styles.ts | 40 +
.../AccountHeaderCard.test.tsx | 72 +
.../AccountHeaderCard/AccountHeaderCard.tsx | 74 +
.../AccountHeaderCard.types.ts | 3 +
.../AccountHeaderCard.test.tsx.snap | 623 ++++++++
.../AccountTag/AccountTag.test.tsx | 31 +
.../AccountTag/AccountTag.tsx | 38 +
.../AccountTag/AccountTag.types.ts | 5 +
.../__snapshots__/AccountTag.test.tsx.snap | 395 +++++
.../ConfirmationFooter.styles.ts | 114 ++
.../ConfirmationFooter.test.tsx | 19 +
.../ConfirmationFooter/ConfirmationFooter.tsx | 19 +
.../FooterButtonGroup.styles.ts | 18 +
.../FooterButtonGroup.test.tsx | 49 +
.../FooterButtonGroup/FooterButtonGroup.tsx | 59 +
.../FooterButtonGroup.test.tsx.snap | 189 +++
.../LegalLinks/LegalLinks.styles.ts | 14 +
.../LegalLinks/LegalLinks.test.tsx | 61 +
.../LegalLinks/LegalLinks.tsx | 52 +
.../__snapshots__/LegalLinks.test.tsx.snap | 187 +++
.../ConfirmationFooter.test.tsx.snap | 162 ++
.../ContractTag/ContractTag.test.tsx | 17 +
.../ContractTag/ContractTag.tsx | 23 +
.../ContractTag/ContractTag.types.ts | 3 +
.../__snapshots__/ContractTag.test.tsx.snap | 69 +
.../EstimatedGasCard.styles.ts | 39 +
.../EstimatedGasCard.test.tsx | 67 +
.../EstimatedGasCard/EstimatedGasCard.tsx | 69 +
.../EstimatedGasCard.types.ts | 4 +
.../EstimatedGasCard.test.tsx.snap | 419 +++++
.../EstimatedGasFeeTooltipContent.styles.ts | 13 +
.../EstimatedGasFeeTooltipContent.test.tsx | 53 +
.../EstimatedGasFeeTooltipContent.tsx | 43 +
...stimatedGasFeeTooltipContent.test.tsx.snap | 67 +
.../RewardsCard/RewardsCard.styles.ts | 16 +
.../RewardsCard/RewardsCard.test.tsx | 98 ++
.../RewardsCard/RewardsCard.tsx | 74 +
.../RewardsCard/RewardsCard.types.ts | 5 +
.../__snapshots__/RewardsCard.test.tsx.snap | 1255 +++++++++++++++
.../TokenValueStack/TokenValueStack.styles.ts | 23 +
.../TokenValueStack/TokenValueStack.test.tsx | 33 +
.../TokenValueStack/TokenValueStack.tsx | 54 +
.../TokenValueStack/TokenValueStack.types.ts | 7 +
.../TokenValueStack.test.tsx.snap | 212 +++
app/components/UI/Stake/routes/index.tsx | 5 +
.../Views/TooltipModal/ToolTipModal.styles.ts | 1 -
app/constants/navigation/Routes.ts | 1 +
app/core/AppConstants.ts | 1 +
locales/languages/en.json | 19 +-
63 files changed, 7149 insertions(+), 1074 deletions(-)
create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.styles.ts
create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.test.tsx
create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.tsx
create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/StakeConfirmationView.types.ts
create mode 100644 app/components/UI/Stake/Views/StakeConfirmationView/__snapshots__/StakeConfirmationView.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/AccountHeaderCard.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountHeaderCard/__snapshots__/AccountHeaderCard.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/AccountTag.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/AccountTag/__snapshots__/AccountTag.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/ConfirmationFooter.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/__snapshots__/FooterButtonGroup.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/LegalLinks.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/LegalLinks/__snapshots__/LegalLinks.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/__snapshots__/ConfirmationFooter.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/ContractTag.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/ContractTag/__snapshots__/ContractTag.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/EstimatedGasCard.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasCard/__snapshots__/EstimatedGasCard.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/EstimatedGasFeeTooltipContent.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/EstimatedGasFeeTooltipContent/__snapshots__/EstimatedGasFeeTooltipContent.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/RewardsCard.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/RewardsCard/__snapshots__/RewardsCard.test.tsx.snap
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.styles.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.test.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.tsx
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/TokenValueStack.types.ts
create mode 100644 app/components/UI/Stake/components/StakingConfirmation/TokenValueStack/__snapshots__/TokenValueStack.test.tsx.snap
diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js
index 8127d67f23c..fd8aa98b1f8 100644
--- a/app/components/UI/Navbar/index.js
+++ b/app/components/UI/Navbar/index.js
@@ -1824,34 +1824,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/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/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": {
From 3fc95b26abdc844e5a89086f65cc0a8442ef042b Mon Sep 17 00:00:00 2001
From: Xiaoming Wang <7315988+dawnseeker8@users.noreply.github.com>
Date: Wed, 16 Oct 2024 16:35:11 +0800
Subject: [PATCH 2/4] fix: ledger sign message screen, legacy account with
legacy path will push balance information outside the border of screen.
(#11550)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
When import a ledger legacy account in Metamask, and user go to click
`Personal Sign` . sometimes the balance is out of border of the personal
sign screen, please see the attached screen image for detail.
This PR fix the layout issue to put the account label below the account
name so that in smaller screen, account balance will not be put out of
box.
## **Related issues**
Fixes: #11522
## **Manual testing steps**
1. Install MM
2. Recover from SRP
3. Add Ledger hardware wallet and connect Ledger 2x account
4. Launch MM test dapp
5. Connect Ledger legacy account on test dapp
6. Sign Permit with account
## **Screenshots/Recordings**
### **Before**
![image](https://github.com/user-attachments/assets/1c4173e4-52ad-4304-8306-a0f54bf42ad4)
### **After**
![image](https://github.com/user-attachments/assets/84f41566-74ee-4026-83bc-c80f028c7cb8)
![image](https://github.com/user-attachments/assets/117defc2-a45d-4b8c-9110-c0a2007719d8)
## **Pre-merge author checklist**
- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
.../Accounts/AccountBase/AccountBase.styles.ts | 1 -
.../components-temp/Accounts/AccountBase/AccountBase.tsx | 8 +++++---
2 files changed, 5 insertions(+), 4 deletions(-)
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)}
- )}
+ )}
From 8034339f2cfe0705578e83e2ca28fe5d50c6e22f Mon Sep 17 00:00:00 2001
From: Jyoti Puri
Date: Wed, 16 Oct 2024 18:41:02 +0530
Subject: [PATCH 3/4] feat: Address value component for use in re-designed
confirmation pages (#11815)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Address value component for use in re-designed confirmation pages
## **Related issues**
Fixes: https://github.com/MetaMask/metamask-mobile/issues/11793
## **Manual testing steps**
1. Enable re-designs locally
2. Go to test dapp
3. Open personal sign page
4. Navigate to network header info section
## **Screenshots/Recordings**
TODO
## **Pre-merge author checklist**
- [X] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [X] I've completed the PR template to the best of my ability
- [X] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [X] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
.../AccountNetworkInfoExpanded.tsx | 5 +-
.../AccountNetworkInfoExpanded.test.tsx.snap | 49 +++++++++++++++---
.../InfoValue/Address/Address.test.tsx | 30 +++++++++++
.../UI/InfoRow/InfoValue/Address/Address.tsx | 14 ++++++
.../__snapshots__/Address.test.tsx.snap | 50 +++++++++++++++++++
.../UI/InfoRow/InfoValue/Address/index.ts | 1 +
.../InfoValue/InfoURL/InfoURL.test.tsx | 2 +-
7 files changed, 141 insertions(+), 10 deletions(-)
create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/Address.test.tsx
create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/Address.tsx
create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/__snapshots__/Address.test.tsx.snap
create mode 100644 app/components/Views/confirmations/components/UI/InfoRow/InfoValue/Address/index.ts
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 () => {
From 7700daec2a373997516780da01b4630f7b1c28c9 Mon Sep 17 00:00:00 2001
From: tommasini <46944231+tommasini@users.noreply.github.com>
Date: Wed, 16 Oct 2024 15:45:32 +0100
Subject: [PATCH 4/4] chore: Add tags to custom traces (#11623)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Tags with measures, only added to engine initialisation span for now
![image](https://github.com/user-attachments/assets/1a25b9a8-c9fa-4c14-a36a-882061e0cc7a)
app launch times pipeline:
https://app.bitrise.io/app/be69d4368ee7e86d/pipelines/3492f0bf-1c24-453c-b9ff-35cf5928bd1a
## **Related issues**
Fixes:
## **Manual testing steps**
1. Go to this page...
2.
3.
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---------
Co-authored-by: Aslau Mario-Daniel
---
app/selectors/nftController.ts | 13 +-
app/selectors/tokensController.ts | 21 +++
app/store/index.ts | 2 +
app/util/sentry/tags/index.test.ts | 229 +++++++++++++++++++++++++++++
app/util/sentry/tags/index.ts | 30 ++++
app/util/trace.test.ts | 54 +++++--
app/util/trace.ts | 141 ++++++++++++++++--
e2e/fixtures/fixture-builder.js | 12 ++
8 files changed, 470 insertions(+), 32 deletions(-)
create mode 100644 app/util/sentry/tags/index.test.ts
create mode 100644 app/util/sentry/tags/index.ts
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: {