diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx
index 8dc96b67932..dd0efd0032b 100644
--- a/app/components/UI/AssetOverview/Balance/Balance.tsx
+++ b/app/components/UI/AssetOverview/Balance/Balance.tsx
@@ -6,8 +6,10 @@ import { useStyles } from '../../../../component-library/hooks';
import styleSheet from './Balance.styles';
import AssetElement from '../../AssetElement';
import { useSelector } from 'react-redux';
-import { selectNetworkName } from '../../../../selectors/networkInfos';
-import { selectChainId } from '../../../../selectors/networkController';
+import {
+ selectChainId,
+ selectNetworkConfigurationByChainId,
+} from '../../../../selectors/networkController';
import {
getTestNetImageByChainId,
getDefaultNetworkByChainId,
@@ -35,6 +37,7 @@ import {
UnpopularNetworkList,
CustomNetworkImgMapping,
} from '../../../../util/networks/customNetworks';
+import { RootState } from '../../../../reducers';
interface BalanceProps {
asset: TokenI;
@@ -91,7 +94,9 @@ export const NetworkBadgeSource = (chainId: Hex, ticker: string) => {
const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
const { styles } = useStyles(styleSheet, {});
const navigation = useNavigation();
- const networkName = useSelector(selectNetworkName);
+ const networkConfigurationByChainId = useSelector((state: RootState) =>
+ selectNetworkConfigurationByChainId(state, asset.chainId as Hex),
+ );
const chainId = useSelector(selectChainId);
const tokenChainId = isPortfolioViewEnabled() ? asset.chainId : chainId;
@@ -156,7 +161,7 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
}
>
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts
new file mode 100644
index 00000000000..0bbc2abee27
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.styles.ts
@@ -0,0 +1,19 @@
+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: {
+ flexGrow: 1,
+ paddingTop: 8,
+ paddingHorizontal: 16,
+ backgroundColor: colors.background.default,
+ justifyContent: 'space-between',
+ },
+ });
+};
+
+export default stylesSheet;
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx
new file mode 100644
index 00000000000..fa1854612e7
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.test.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import StakeEarningsHistoryView from './StakeEarningsHistoryView';
+import useStakingEarningsHistory from '../../hooks/useStakingEarningsHistory';
+import { MOCK_STAKED_ETH_ASSET } from '../../__mocks__/mockData';
+import { fireLayoutEvent } from '../../../../../util/testUtils/react-native-svg-charts';
+import { getStakingNavbar } from '../../../Navbar';
+import renderWithProvider from '../../../../../util/test/renderWithProvider';
+import { backgroundState } from '../../../../../util/test/initial-root-state';
+import { Hex } from '@metamask/utils';
+
+jest.mock('../../../Navbar');
+jest.mock('../../hooks/useStakingEarningsHistory');
+
+const mockNavigation = {
+ navigate: jest.fn(),
+ setOptions: jest.fn(),
+};
+
+jest.mock('@react-navigation/native', () => {
+ const actualNav = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualNav,
+ useNavigation: () => mockNavigation,
+ useRoute: () => ({
+ key: '1',
+ name: 'params',
+ params: { asset: MOCK_STAKED_ETH_ASSET },
+ }),
+ };
+});
+jest.mock('react-native-svg-charts', () => {
+ const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component
+ return {
+ ...reactNativeSvgCharts,
+ Grid: () => <>>,
+ };
+});
+
+(useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '1000000000000000000',
+ sumRewards: '1000000000000000000',
+ },
+ {
+ dateStr: '2023-01-02',
+ dailyRewards: '1000000000000000000',
+ sumRewards: '2000000000000000000',
+ },
+ ],
+ isLoading: false,
+ error: null,
+});
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ CurrencyRateController: {
+ currentCurrency: 'usd',
+ currencyRates: {
+ ETH: {
+ conversionRate: 3363.79,
+ },
+ },
+ },
+ NetworkController: {
+ selectedNetworkClientId: 'selectedNetworkClientId',
+ networkConfigurationsByChainId: {
+ '0x1': {
+ nativeCurrency: 'ETH',
+ chainId: '0x1' as Hex,
+ rpcEndpoints: [
+ {
+ networkClientId: 'selectedNetworkClientId',
+ },
+ ],
+ defaultRpcEndpointIndex: 0,
+ },
+ },
+ },
+ },
+ },
+};
+
+const earningsHistoryView = ;
+
+describe('StakeEarningsHistoryView', () => {
+ it('renders correctly and matches snapshot', () => {
+ const renderedView = renderWithProvider(earningsHistoryView, {
+ state: mockInitialState,
+ });
+ fireLayoutEvent(renderedView.root);
+ expect(renderedView.toJSON()).toMatchSnapshot();
+ });
+
+ it('calls navigation setOptions to get staking navigation bar', () => {
+ const renderedView = renderWithProvider(earningsHistoryView, {
+ state: mockInitialState,
+ });
+ fireLayoutEvent(renderedView.root);
+ expect(mockNavigation.setOptions).toHaveBeenCalled();
+ expect(getStakingNavbar).toHaveBeenCalled();
+ });
+});
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx
new file mode 100644
index 00000000000..7be8d6485c5
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.tsx
@@ -0,0 +1,44 @@
+import { useNavigation, useRoute } from '@react-navigation/native';
+import React, { useEffect } from 'react';
+import { View } from 'react-native';
+import { ScrollView } from 'react-native-gesture-handler';
+import { strings } from '../../../../../../locales/i18n';
+import { useStyles } from '../../../../hooks/useStyles';
+import { getStakingNavbar } from '../../../Navbar';
+import StakingEarningsHistory from '../../components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory';
+import styleSheet from './StakeEarningsHistoryView.styles';
+import { StakeEarningsHistoryViewRouteParams } from './StakeEarningsHistoryView.types';
+
+const StakeEarningsHistoryView = () => {
+ const navigation = useNavigation();
+ const route = useRoute();
+ const { styles, theme } = useStyles(styleSheet, {});
+ const { asset } = route.params;
+
+ useEffect(() => {
+ navigation.setOptions(
+ getStakingNavbar(
+ strings('stake.earnings_history_title', {
+ ticker: asset.ticker,
+ }),
+ navigation,
+ theme.colors,
+ {
+ backgroundColor: theme.colors.background.default,
+ hasCancelButton: false,
+ hasBackButton: true,
+ },
+ ),
+ );
+ }, [navigation, theme.colors, asset.ticker]);
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default StakeEarningsHistoryView;
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts
new file mode 100644
index 00000000000..106bb6ead4e
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/StakeEarningsHistoryView.types.ts
@@ -0,0 +1,9 @@
+import { TokenI } from '../../../Tokens/types';
+
+export interface StakeEarningsHistoryViewRouteParams {
+ key: string;
+ name: string;
+ params: {
+ asset: TokenI;
+ };
+}
diff --git a/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap
new file mode 100644
index 00000000000..6469b6b7eda
--- /dev/null
+++ b/app/components/UI/Stake/Views/StakeEarningsHistoryView/__snapshots__/StakeEarningsHistoryView.test.tsx.snap
@@ -0,0 +1,980 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StakeEarningsHistoryView renders correctly and matches snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+ 7D
+
+
+
+
+
+
+
+
+ M
+
+
+
+
+
+
+
+
+ Y
+
+
+
+
+
+
+
+
+
+ 2
+
+ ETH
+
+
+ Lifetime earnings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Payout history
+
+
+
+
+ 2023
+
+
+
+
+
+ January
+
+
+
+
+
+ +
+ 2
+
+ ETH
+
+
+
+
+ $6727.58
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/__mocks__/mockData.ts b/app/components/UI/Stake/__mocks__/mockData.ts
index ab3e2b9ff3e..f0fcabbca3d 100644
--- a/app/components/UI/Stake/__mocks__/mockData.ts
+++ b/app/components/UI/Stake/__mocks__/mockData.ts
@@ -10,14 +10,29 @@ import { Contract } from 'ethers';
import { Stake } from '../sdk/stakeSdkProvider';
export const MOCK_STAKED_ETH_ASSET = {
+ decimals: 18,
+ address: '0x0000000000000000000000000000000000000000',
chainId: '0x1',
balance: '4.9999 ETH',
balanceFiat: '$13,292.20',
name: 'Staked Ethereum',
- symbol: 'ETH',
+ symbol: 'Ethereum',
+ ticker: 'ETH',
isETH: true,
} as TokenI;
+export const MOCK_USDC_ASSET = {
+ decimals: 6,
+ address: '0xUSDC000000000000000000000000000000000000',
+ chainId: '0x1',
+ balance: '200.9999 USDC',
+ balanceFiat: '$200.98',
+ name: 'USD Coin',
+ symbol: 'USD Coin',
+ ticker: 'USDC',
+ isETH: false,
+} as TokenI;
+
export const MOCK_GET_POOLED_STAKES_API_RESPONSE: PooledStakes = {
accounts: [
{
diff --git a/app/components/UI/Stake/components/GasImpactModal/index.tsx b/app/components/UI/Stake/components/GasImpactModal/index.tsx
index dabbaca7c5d..c2872241556 100644
--- a/app/components/UI/Stake/components/GasImpactModal/index.tsx
+++ b/app/components/UI/Stake/components/GasImpactModal/index.tsx
@@ -126,7 +126,7 @@ const GasImpactModal = ({ route }: GasImpactModalProps) => {
- {strings('stake.gas_cost_impact_warning')}
+ {strings('stake.gas_cost_impact_warning', { percentOverDeposit: 30 })}
{
setHasSentViewingStakingRewardsMetric,
] = useState(false);
- const networkName = useSelector(selectNetworkName);
+ const networkConfigurationByChainId = useSelector((state: RootState) =>
+ selectNetworkConfigurationByChainId(state, asset.chainId as Hex),
+ );
const { isEligible: isEligibleForPooledStaking } = useStakingEligibility();
@@ -216,7 +219,7 @@ const StakingBalanceContent = ({ asset }: StakingBalanceProps) => {
asset.chainId as Hex,
asset.ticker ?? asset.symbol,
)}
- name={networkName}
+ name={networkConfigurationByChainId?.name}
/>
}
>
diff --git a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
index a3a8efd1247..1ae10dfbaae 100644
--- a/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingBalance/__snapshots__/StakingBalance.test.tsx.snap
@@ -16,7 +16,7 @@ exports[`StakingBalance render matches snapshot 1`] = `
"paddingVertical": 10,
}
}
- testID="asset-ETH"
+ testID="asset-Ethereum"
>
({
describe('Staking Earnings', () => {
it('should render correctly', () => {
const { toJSON, getByText } = renderWithProvider(
- ,
+ ,
{
state: STATE_MOCK,
},
@@ -78,6 +91,7 @@ describe('Staking Earnings', () => {
expect(getByText(strings('stake.annual_rate'))).toBeDefined();
expect(getByText(strings('stake.lifetime_rewards'))).toBeDefined();
expect(getByText(strings('stake.estimated_annual_earnings'))).toBeDefined();
+ expect(getByText(strings('stake.view_earnings_history'))).toBeDefined();
expect(toJSON()).toMatchSnapshot();
});
});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts
new file mode 100644
index 00000000000..f39dd555f4d
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.constants.ts
@@ -0,0 +1,9 @@
+import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types';
+
+export const EARNINGS_HISTORY_TIME_PERIOD_DEFAULT = DateRange.MONTHLY;
+export const EARNINGS_HISTORY_DAYS_LIMIT = 730;
+export const EARNINGS_HISTORY_CHART_BAR_LIMIT = {
+ [DateRange.DAILY]: 7,
+ [DateRange.MONTHLY]: 12,
+ [DateRange.YEARLY]: 2,
+};
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx
new file mode 100644
index 00000000000..f2bf3bd0a50
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.test.tsx
@@ -0,0 +1,332 @@
+import React from 'react';
+import { fireEvent } from '@testing-library/react-native';
+import StakingEarningsHistory from './StakingEarningsHistory';
+import useStakingEarningsHistory from '../../../hooks/useStakingEarningsHistory';
+import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../../../util/test/accountsControllerTestUtils';
+import { backgroundState } from '../../../../../../util/test/initial-root-state';
+import { Hex } from '@metamask/smart-transactions-controller/dist/types';
+import { TokenI } from '../../../../Tokens/types';
+
+jest.mock('../../../hooks/useStakingEarningsHistory');
+jest.mock('react-native-svg-charts', () => {
+ const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts');
+ return {
+ ...reactNativeSvgCharts,
+ Grid: () => <>>,
+ };
+});
+
+const mockInitialState = {
+ settings: {},
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ CurrencyRateController: {
+ currentCurrency: 'usd',
+ currencyRates: {
+ ETH: {
+ conversionRate: 3363.79,
+ },
+ },
+ },
+ TokenRatesController: {
+ marketData: {
+ '0x1': {
+ '0xUSDC000000000000000000000000000000000000': {
+ price: 0.0002990514020561363,
+ },
+ },
+ },
+ },
+ NetworkController: {
+ selectedNetworkClientId: 'selectedNetworkClientId',
+ networkConfigurationsByChainId: {
+ '0x1': {
+ nativeCurrency: 'ETH',
+ chainId: '0x1' as Hex,
+ rpcEndpoints: [
+ {
+ networkClientId: 'selectedNetworkClientId',
+ },
+ ],
+ defaultRpcEndpointIndex: 0,
+ },
+ },
+ },
+ },
+ },
+};
+
+describe('StakingEarningsHistory', () => {
+ beforeEach(() => {
+ (useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2022-12-31',
+ dailyRewards: '442219562575615',
+ sumRewards: '442219562575615',
+ },
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '542219562575615',
+ sumRewards: '984439125151230',
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+ });
+
+ it('renders correctly with earnings history', () => {
+ const { getByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(getByText('7D')).toBeTruthy();
+ expect(getByText('M')).toBeTruthy();
+ expect(getByText('Y')).toBeTruthy();
+ expect(getByText('Lifetime earnings')).toBeTruthy();
+ expect(getByText('0.00098 ETH')).toBeTruthy();
+ expect(getByText('December')).toBeTruthy();
+ expect(getByText('$1.49')).toBeTruthy();
+ expect(getByText('+ 0.00044 ETH')).toBeTruthy();
+ expect(getByText('January')).toBeTruthy();
+ expect(getByText('$1.82')).toBeTruthy();
+ expect(getByText('+ 0.00054 ETH')).toBeTruthy();
+ });
+
+ it('renders correctly with trailing zero values', () => {
+ (useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2022-11-02',
+ dailyRewards: '0',
+ sumRewards: '0',
+ },
+ {
+ dateStr: '2022-12-31',
+ dailyRewards: '442219562575615',
+ sumRewards: '442219562575615',
+ },
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '542219562575615',
+ sumRewards: '984439125151230',
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(getByText('Lifetime earnings')).toBeTruthy();
+ expect(getByText('0.00098 ETH')).toBeTruthy();
+ expect(getByText('December')).toBeTruthy();
+ expect(getByText('$1.49')).toBeTruthy();
+ expect(getByText('+ 0.00044 ETH')).toBeTruthy();
+ expect(getByText('January')).toBeTruthy();
+ expect(getByText('$1.82')).toBeTruthy();
+ expect(getByText('+ 0.00054 ETH')).toBeTruthy();
+ expect(queryByText('November')).toBeFalsy();
+ });
+
+ it('should render correctly with an erc20 token asset', () => {
+ (useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2022-12-31',
+ dailyRewards: '100000000',
+ sumRewards: '100000000',
+ },
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '300000000',
+ sumRewards: '400000000',
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ const erc20Asset = {
+ address: '0xUSDC000000000000000000000000000000000000',
+ chainId: '0x1',
+ name: 'USD Coin',
+ symbol: 'USD Coin',
+ ticker: 'USDC',
+ isETH: false,
+ decimals: 6,
+ } as TokenI;
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(getByText('Lifetime earnings')).toBeTruthy();
+ expect(getByText('400 USDC')).toBeTruthy();
+ expect(getByText('December')).toBeTruthy();
+ expect(getByText('$100.59')).toBeTruthy();
+ expect(getByText('+ 100 USDC')).toBeTruthy();
+ expect(getByText('January')).toBeTruthy();
+ expect(getByText('$301.78')).toBeTruthy();
+ expect(getByText('+ 300 USDC')).toBeTruthy();
+ expect(queryByText('November')).toBeFalsy();
+ });
+
+ it('should render correctly when switching currency setting', () => {
+ (useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2022-12-31',
+ dailyRewards: '100000000',
+ sumRewards: '100000000',
+ },
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '300000000',
+ sumRewards: '400000000',
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ const erc20Asset = {
+ address: '0xUSDC000000000000000000000000000000000000',
+ chainId: '0x1',
+ name: 'USD Coin',
+ symbol: 'USD Coin',
+ ticker: 'USDC',
+ isETH: false,
+ decimals: 6,
+ } as TokenI;
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ {
+ state: {
+ ...mockInitialState,
+ engine: {
+ ...mockInitialState.engine,
+ backgroundState: {
+ ...mockInitialState.engine.backgroundState,
+ CurrencyRateController: {
+ ...mockInitialState.engine.backgroundState
+ .CurrencyRateController,
+ currentCurrency: 'xlm',
+ currencyRates: {
+ ETH: {
+ conversionRate: 7683.22,
+ },
+ },
+ },
+ },
+ },
+ },
+ },
+ );
+
+ expect(getByText('Lifetime earnings')).toBeTruthy();
+ expect(getByText('400 USDC')).toBeTruthy();
+ expect(getByText('December')).toBeTruthy();
+ expect(getByText('229.77 XLM')).toBeTruthy();
+ expect(getByText('+ 100 USDC')).toBeTruthy();
+ expect(getByText('January')).toBeTruthy();
+ expect(getByText('689.3 XLM')).toBeTruthy();
+ expect(getByText('+ 300 USDC')).toBeTruthy();
+ expect(queryByText('November')).toBeFalsy();
+ });
+
+ it('renders correctly with inner and trailing zero values', () => {
+ (useStakingEarningsHistory as jest.Mock).mockReturnValue({
+ earningsHistory: [
+ {
+ dateStr: '2022-10-02',
+ dailyRewards: '0',
+ sumRewards: '0',
+ },
+ {
+ dateStr: '2022-11-30',
+ dailyRewards: '442219562575615',
+ sumRewards: '442219562575615',
+ },
+ {
+ dateStr: '2022-12-31',
+ dailyRewards: '0',
+ sumRewards: '442219562575615',
+ },
+ {
+ dateStr: '2023-01-01',
+ dailyRewards: '542219562575615',
+ sumRewards: '984439125151230',
+ },
+ ],
+ isLoading: false,
+ error: null,
+ });
+
+ const { getByText, queryByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ expect(getByText('Lifetime earnings')).toBeTruthy();
+ expect(getByText('0.00098 ETH')).toBeTruthy();
+ expect(getByText('November')).toBeTruthy();
+ expect(getByText('$1.49')).toBeTruthy();
+ expect(getByText('+ 0.00044 ETH')).toBeTruthy();
+ expect(getByText('December')).toBeTruthy();
+ expect(getByText('+ 0 ETH')).toBeTruthy();
+ expect(getByText('$0')).toBeTruthy();
+ expect(getByText('January')).toBeTruthy();
+ expect(getByText('$1.82')).toBeTruthy();
+ expect(getByText('+ 0.00054 ETH')).toBeTruthy();
+ expect(queryByText('October')).toBeFalsy();
+ });
+
+ it('calls onTimePeriodChange and updates the selected time period', () => {
+ const { getByText } = renderWithProvider(
+ ,
+ {
+ state: mockInitialState,
+ },
+ );
+
+ const timePeriodButton7D = getByText('7D');
+ fireEvent.press(timePeriodButton7D);
+
+ expect(getByText('December 31')).toBeTruthy();
+ expect(getByText('$1.49')).toBeTruthy();
+ expect(getByText('+ 0.00044 ETH')).toBeTruthy();
+ expect(getByText('January 1')).toBeTruthy();
+ expect(getByText('$1.82')).toBeTruthy();
+ expect(getByText('+ 0.00054 ETH')).toBeTruthy();
+
+ const timePeriodButtonY = getByText('Y');
+ fireEvent.press(timePeriodButtonY);
+
+ expect(getByText('2022')).toBeTruthy();
+ expect(getByText('$1.49')).toBeTruthy();
+ expect(getByText('+ 0.00044 ETH')).toBeTruthy();
+ expect(getByText('2023')).toBeTruthy();
+ expect(getByText('$1.82')).toBeTruthy();
+ expect(getByText('+ 0.00054 ETH')).toBeTruthy();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx
new file mode 100644
index 00000000000..edd467066ca
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.tsx
@@ -0,0 +1,307 @@
+import { BN } from 'ethereumjs-util';
+import React, { useMemo, useState } from 'react';
+import { View } from 'react-native';
+import useStakingEarningsHistory, {
+ EarningHistory,
+} from '../../../hooks/useStakingEarningsHistory';
+import { StakingEarningsHistoryChart } from './StakingEarningsHistoryChart/StakingEarningsHistoryChart';
+import { ChainId } from '@metamask/controller-utils';
+import { useSelector } from 'react-redux';
+import {
+ selectCurrencyRates,
+ selectCurrentCurrency,
+} from '../../../../../../selectors/currencyRateController';
+import { selectNetworkConfigurations } from '../../../../../../selectors/networkController';
+import { selectTokenMarketData } from '../../../../../../selectors/tokenRatesController';
+import { Hex } from '../../../../../../util/smart-transactions/smart-publish-hook';
+import {
+ EarningsHistoryData,
+ StakingEarningsHistoryProps,
+ TimePeriodGroupInfo,
+} from './StakingEarningsHistory.types';
+import StakingEarningsHistoryList from './StakingEarningsHistoryList/StakingEarningsHistoryList';
+import TimePeriodButtonGroup from './StakingEarningsTimePeriod/StakingEarningsTimePeriod';
+import {
+ EARNINGS_HISTORY_CHART_BAR_LIMIT,
+ EARNINGS_HISTORY_DAYS_LIMIT,
+ EARNINGS_HISTORY_TIME_PERIOD_DEFAULT,
+} from './StakingEarningsHistory.constants';
+import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types';
+import {
+ fillGapsInEarningsHistory,
+ formatRewardsFiat,
+ formatRewardsNumber,
+ formatRewardsWei,
+ getEntryTimePeriodGroupInfo,
+} from './StakingEarningsHistory.utils';
+
+const StakingEarningsHistory = ({ asset }: StakingEarningsHistoryProps) => {
+ const [selectedTimePeriod, setSelectedTimePeriod] = useState(
+ EARNINGS_HISTORY_TIME_PERIOD_DEFAULT,
+ );
+ const currentCurrency: string = useSelector(selectCurrentCurrency);
+ const multiChainMarketData = useSelector(selectTokenMarketData);
+ const multiChainCurrencyRates = useSelector(selectCurrencyRates);
+ const networkConfigurations = useSelector(selectNetworkConfigurations);
+ const {
+ earningsHistory,
+ isLoading: isLoadingEarningsHistory,
+ error: errorEarningsHistory,
+ } = useStakingEarningsHistory({
+ chainId: asset.chainId as ChainId,
+ limitDays: EARNINGS_HISTORY_DAYS_LIMIT,
+ });
+
+ const ticker = asset.ticker ?? asset.symbol;
+ // get exchange rates for asset chainId
+ const exchangeRates = multiChainMarketData[asset.chainId as Hex];
+ let exchangeRate = 0;
+ if (exchangeRates) {
+ exchangeRate = exchangeRates[asset.address as Hex]?.price;
+ }
+ // attempt to find native currency for asset chainId
+ const nativeCurrency =
+ networkConfigurations[asset.chainId as Hex]?.nativeCurrency;
+ let conversionRate = 0;
+ // if native currency is found, use it to get conversion rate
+ if (nativeCurrency) {
+ conversionRate =
+ multiChainCurrencyRates[nativeCurrency]?.conversionRate ?? conversionRate;
+ }
+
+ const transformedEarningsHistory = useMemo(
+ () =>
+ fillGapsInEarningsHistory(earningsHistory, EARNINGS_HISTORY_DAYS_LIMIT),
+ [earningsHistory],
+ );
+
+ const { earningsHistoryChartData, earningsHistoryListData } = useMemo(() => {
+ const historyData: EarningsHistoryData = {
+ earningsHistoryChartData: {
+ earnings: [],
+ earningsTotal: '0',
+ ticker,
+ },
+ earningsHistoryListData: [],
+ };
+
+ if (
+ isLoadingEarningsHistory ||
+ errorEarningsHistory ||
+ !transformedEarningsHistory ||
+ transformedEarningsHistory.length === 0
+ )
+ return historyData;
+
+ const barLimit = EARNINGS_HISTORY_CHART_BAR_LIMIT[selectedTimePeriod];
+ let rewardsTotalForChartTimePeriodBN = new BN(0);
+ let rewardsTotalForListTimePeriodBN = new BN(0);
+ let trailingZeroHistoryListValues = 0;
+ let currentTimePeriodChartGroup: string | null = null;
+ let currentTimePeriodListGroup: string | null = null;
+ let lastEntryTimePeriodGroupInfo: TimePeriodGroupInfo = {
+ dateStr: '',
+ chartGroup: '',
+ chartGroupLabel: '',
+ listGroup: '',
+ listGroupLabel: '',
+ listGroupHeader: '',
+ };
+ let prevLastEntryTimePeriodGroupInfo: TimePeriodGroupInfo = {
+ dateStr: '',
+ chartGroup: '',
+ chartGroupLabel: '',
+ listGroup: '',
+ listGroupLabel: '',
+ listGroupHeader: '',
+ };
+
+ // update earnings total from last sumRewards key
+ const updateEarningsTotal = (entry: EarningHistory) => {
+ historyData.earningsHistoryChartData.earningsTotal = formatRewardsWei(
+ entry.sumRewards,
+ asset,
+ );
+ };
+
+ // handles chart specific data per entry
+ const handleChartData = (entry: EarningHistory) => {
+ const rewardsBN = new BN(entry.dailyRewards);
+ const { chartGroup: newChartGroup } = lastEntryTimePeriodGroupInfo;
+ // add rewards to total for time period
+ if (currentTimePeriodChartGroup === newChartGroup) {
+ rewardsTotalForChartTimePeriodBN =
+ rewardsTotalForChartTimePeriodBN.add(rewardsBN);
+ } else {
+ historyData.earningsHistoryChartData.earnings.unshift({
+ value: parseFloat(
+ formatRewardsWei(
+ rewardsTotalForChartTimePeriodBN.toString(),
+ asset,
+ true,
+ ),
+ ),
+ label: prevLastEntryTimePeriodGroupInfo.chartGroupLabel,
+ });
+ // update current time period group
+ currentTimePeriodChartGroup = newChartGroup;
+ // reset for next time period
+ rewardsTotalForChartTimePeriodBN = new BN(rewardsBN);
+ }
+ };
+
+ // handles list specific data per entry
+ const handleListData = (entry: EarningHistory) => {
+ const rewardsBN = new BN(entry.dailyRewards);
+ const { listGroup: newListGroup } = lastEntryTimePeriodGroupInfo;
+ if (currentTimePeriodListGroup === newListGroup) {
+ rewardsTotalForListTimePeriodBN =
+ rewardsTotalForListTimePeriodBN.add(rewardsBN);
+ } else {
+ if (!rewardsTotalForListTimePeriodBN.gt(new BN(0))) {
+ trailingZeroHistoryListValues++;
+ } else {
+ trailingZeroHistoryListValues = 0;
+ }
+ historyData.earningsHistoryListData.push({
+ label: prevLastEntryTimePeriodGroupInfo.listGroupLabel,
+ groupLabel: prevLastEntryTimePeriodGroupInfo.chartGroupLabel,
+ groupHeader: prevLastEntryTimePeriodGroupInfo.listGroupHeader,
+ amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset),
+ amountSecondaryText: formatRewardsFiat(
+ rewardsTotalForListTimePeriodBN,
+ asset,
+ currentCurrency,
+ conversionRate,
+ exchangeRate,
+ ),
+ ticker,
+ });
+
+ // reset for next time period
+ currentTimePeriodListGroup = newListGroup;
+ rewardsTotalForListTimePeriodBN = new BN(rewardsBN);
+ }
+ };
+
+ const handleListTrailingZeros = () => {
+ if (trailingZeroHistoryListValues > 0) {
+ historyData.earningsHistoryListData.splice(
+ historyData.earningsHistoryListData.length -
+ trailingZeroHistoryListValues,
+ trailingZeroHistoryListValues,
+ );
+ }
+ };
+
+ const finalizeListData = () => {
+ if (historyData.earningsHistoryChartData.earnings.length < barLimit) {
+ if (!rewardsTotalForListTimePeriodBN.gt(new BN(0))) {
+ trailingZeroHistoryListValues++;
+ } else {
+ trailingZeroHistoryListValues = 0;
+ }
+ historyData.earningsHistoryListData.push({
+ label: lastEntryTimePeriodGroupInfo.listGroupLabel,
+ groupLabel: lastEntryTimePeriodGroupInfo.chartGroupLabel,
+ groupHeader: lastEntryTimePeriodGroupInfo.listGroupHeader,
+ amount: formatRewardsWei(rewardsTotalForListTimePeriodBN, asset),
+ amountSecondaryText: formatRewardsFiat(
+ rewardsTotalForListTimePeriodBN,
+ asset,
+ currentCurrency,
+ conversionRate,
+ exchangeRate,
+ ),
+ ticker,
+ });
+ }
+ // removes trailing zeros from history list
+ handleListTrailingZeros();
+ };
+
+ const finalizeChartData = () => {
+ if (historyData.earningsHistoryChartData.earnings.length < barLimit) {
+ historyData.earningsHistoryChartData.earnings.unshift({
+ value: parseFloat(
+ formatRewardsWei(
+ rewardsTotalForChartTimePeriodBN.toString(),
+ asset,
+ true,
+ ),
+ ),
+ label: lastEntryTimePeriodGroupInfo.chartGroupLabel,
+ });
+ }
+ };
+
+ const finalizeProcessing = () => {
+ finalizeListData();
+ finalizeChartData();
+ };
+
+ const processEntry = (entry: EarningHistory, i: number) => {
+ if (i === transformedEarningsHistory.length - 1) {
+ updateEarningsTotal(entry);
+ }
+ prevLastEntryTimePeriodGroupInfo = {
+ ...lastEntryTimePeriodGroupInfo,
+ };
+ lastEntryTimePeriodGroupInfo = getEntryTimePeriodGroupInfo(
+ entry.dateStr,
+ selectedTimePeriod,
+ );
+ if (!currentTimePeriodChartGroup) {
+ currentTimePeriodChartGroup = lastEntryTimePeriodGroupInfo.chartGroup;
+ currentTimePeriodListGroup = lastEntryTimePeriodGroupInfo.listGroup;
+ }
+ if (historyData.earningsHistoryChartData.earnings.length < barLimit) {
+ handleChartData(entry);
+ handleListData(entry);
+ }
+ };
+
+ const processEntries = () => {
+ for (let i = transformedEarningsHistory.length - 1; i >= 0; i--) {
+ processEntry(transformedEarningsHistory[i], i);
+ }
+ finalizeProcessing();
+ };
+
+ processEntries();
+
+ return historyData;
+ }, [
+ selectedTimePeriod,
+ isLoadingEarningsHistory,
+ errorEarningsHistory,
+ transformedEarningsHistory,
+ asset,
+ ticker,
+ currentCurrency,
+ conversionRate,
+ exchangeRate,
+ ]);
+
+ const onTimePeriodChange = (newTimePeriod: DateRange) => {
+ setSelectedTimePeriod(newTimePeriod);
+ };
+
+ return isLoadingEarningsHistory ? null : (
+
+
+ formatRewardsNumber(value, asset)}
+ />
+
+
+ );
+};
+
+export default StakingEarningsHistory;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts
new file mode 100644
index 00000000000..ac7b345c38c
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.types.ts
@@ -0,0 +1,25 @@
+import { TokenI } from '../../../../Tokens/types';
+import { StakingEarningsHistoryChartData } from './StakingEarningsHistoryChart/StakingEarningsHistoryChart.types';
+import { StakingEarningsHistoryListData } from './StakingEarningsHistoryList/StakingEarningsHistoryList.types';
+
+export interface StakingEarningsHistoryProps {
+ asset: TokenI;
+}
+
+export interface TimePeriodGroupInfo {
+ dateStr: string;
+ chartGroup: string;
+ chartGroupLabel: string;
+ listGroup: string;
+ listGroupLabel: string;
+ listGroupHeader: string;
+}
+
+export interface EarningsHistoryData {
+ earningsHistoryChartData: {
+ earnings: StakingEarningsHistoryChartData[];
+ earningsTotal: string;
+ ticker: string;
+ };
+ earningsHistoryListData: StakingEarningsHistoryListData[];
+}
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts
new file mode 100644
index 00000000000..5bba1a3c256
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.test.ts
@@ -0,0 +1,158 @@
+import {
+ MOCK_STAKED_ETH_ASSET,
+ MOCK_USDC_ASSET,
+} from '../../../__mocks__/mockData';
+import {
+ getEntryTimePeriodGroupInfo,
+ fillGapsInEarningsHistory,
+ formatRewardsWei,
+ formatRewardsNumber,
+ formatRewardsFiat,
+} from './StakingEarningsHistory.utils';
+import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types';
+
+const mockChartGroupDaily = {
+ dateStr: '2023-10-01',
+ chartGroup: '2023-10-01',
+ chartGroupLabel: 'October 1',
+ listGroup: '2023-10-01',
+ listGroupLabel: 'October 1',
+ listGroupHeader: '',
+};
+
+const mockChartGroupMonthly = {
+ dateStr: '2023-10-01',
+ chartGroup: '2023-10',
+ chartGroupLabel: 'October',
+ listGroup: '2023-10',
+ listGroupLabel: 'October',
+ listGroupHeader: '2023',
+};
+
+const mockChartGroupYearly = {
+ dateStr: '2023-10-01',
+ chartGroup: '2023',
+ chartGroupLabel: '2023',
+ listGroup: '2023',
+ listGroupLabel: '2023',
+ listGroupHeader: '',
+};
+
+describe('StakingEarningsHistory Utils', () => {
+ describe('getEntryTimePeriodGroupInfo', () => {
+ it('should return correct time period group info for daily', () => {
+ const result = getEntryTimePeriodGroupInfo(
+ mockChartGroupDaily.dateStr,
+ DateRange.DAILY,
+ );
+ expect(result).toEqual(mockChartGroupDaily);
+ });
+
+ it('should return correct time period group info for monthly', () => {
+ const result = getEntryTimePeriodGroupInfo(
+ mockChartGroupMonthly.dateStr,
+ DateRange.MONTHLY,
+ );
+ expect(result).toEqual(mockChartGroupMonthly);
+ });
+
+ it('should return correct time period group info for yearly', () => {
+ const result = getEntryTimePeriodGroupInfo(
+ mockChartGroupYearly.dateStr,
+ DateRange.YEARLY,
+ );
+ expect(result).toEqual(mockChartGroupYearly);
+ });
+
+ it('should throw an error for unsupported time period', () => {
+ expect(() =>
+ getEntryTimePeriodGroupInfo('2023-10-01', 'unsupported' as DateRange),
+ ).toThrow('Unsupported time period');
+ });
+ });
+
+ describe('fillGapsInEarningsHistory', () => {
+ it('should fill gaps in earnings history', () => {
+ const earningsHistory = [
+ { dateStr: '2023-10-01', dailyRewards: '10', sumRewards: '10' },
+ { dateStr: '2023-10-02', dailyRewards: '20', sumRewards: '30' },
+ ];
+ const result = fillGapsInEarningsHistory(earningsHistory, 5);
+ expect(result.length).toBe(5);
+ expect(result[0].dateStr).toBe('2023-09-28');
+ expect(result[1].dateStr).toBe('2023-09-29');
+ expect(result[2].dateStr).toBe('2023-09-30');
+ });
+
+ it('should return an empty array if earnings history is null', () => {
+ const result = fillGapsInEarningsHistory(null, 5);
+ expect(result).toEqual([]);
+ });
+
+ it('should return an empty array if earnings history is empty', () => {
+ const result = fillGapsInEarningsHistory([], 5);
+ expect(result).toEqual([]);
+ });
+ });
+
+ describe('formatRewardsWei', () => {
+ it('should format rewards value with special characters', () => {
+ const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET);
+ expect(result).toBe('< 0.00001');
+ });
+
+ it('should format rewards value with special characters when asset.isETH is false', () => {
+ const result = formatRewardsWei('1', MOCK_USDC_ASSET);
+ expect(result).toBe('< 0.00001');
+ });
+
+ it('should format rewards value without special characters', () => {
+ const result = formatRewardsWei('1', MOCK_STAKED_ETH_ASSET, true);
+ expect(result).toBe('0.000000000000000001');
+ });
+
+ it('should format rewards value without special characters when asset.isETH is false', () => {
+ const result = formatRewardsWei('1', MOCK_USDC_ASSET, true);
+ expect(result).toBe('0.000001');
+ });
+ });
+
+ describe('formatRewardsNumber', () => {
+ it('should format short rewards number correctly', () => {
+ const result = formatRewardsNumber(1.456, MOCK_STAKED_ETH_ASSET);
+ expect(result).toBe('1.456');
+ });
+
+ it('should format long rewards number with 5 decimals', () => {
+ const result = formatRewardsNumber(
+ 1.456234265436536,
+ MOCK_STAKED_ETH_ASSET,
+ );
+ expect(result).toBe('1.45623');
+ });
+ });
+
+ describe('formatRewardsFiat', () => {
+ it('should format rewards to fiat currency', () => {
+ const result = formatRewardsFiat(
+ '1000000000000000000',
+ MOCK_STAKED_ETH_ASSET,
+ 'usd',
+ 2000,
+ 1,
+ );
+ expect(result).toBe('$2000');
+ });
+
+ it('should format rewards to fiat currency when asset.isETH is false', () => {
+ const result = formatRewardsFiat(
+ '1000000',
+ MOCK_USDC_ASSET,
+ 'usd',
+ 2000,
+ 1,
+ );
+ expect(result).toBe('$2000');
+ });
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts
new file mode 100644
index 00000000000..0c305cca66b
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistory.utils.ts
@@ -0,0 +1,177 @@
+import {
+ balanceToFiatNumber,
+ renderFromTokenMinimalUnit,
+ renderFiat,
+ fromTokenMinimalUnit,
+ weiToFiatNumber,
+ renderFromWei,
+ fromWei,
+} from '../../../../../../util/number';
+import { BN } from 'ethereumjs-util';
+import { TimePeriodGroupInfo } from './StakingEarningsHistory.types';
+import { DateRange } from './StakingEarningsTimePeriod/StakingEarningsTimePeriod.types';
+import BigNumber from 'bignumber.js';
+import { EarningHistory } from '../../../hooks/useStakingEarningsHistory';
+import { TokenI } from '../../../../Tokens/types';
+
+/**
+ * Formats the date string into a time period group info object
+ *
+ * @param {string} dateStr - The date string YYYY-MM-DD to format.
+ * @param {DateRange} selectedTimePeriod - The selected time period.
+ * @returns {TimePeriodGroupInfo} The formatted time period group info object.
+ */
+export const getEntryTimePeriodGroupInfo = (
+ dateStr: string,
+ selectedTimePeriod: DateRange,
+): TimePeriodGroupInfo => {
+ const [newYear, newMonth] = dateStr.split('-');
+ const date = new Date(dateStr);
+ date.setUTCHours(0, 0, 0, 0);
+ const timePeriodInfo: TimePeriodGroupInfo = {
+ dateStr,
+ chartGroup: '',
+ chartGroupLabel: '',
+ listGroup: '',
+ listGroupLabel: '',
+ listGroupHeader: '',
+ };
+ const dayLabel = date.toLocaleString('fullwide', {
+ month: 'long',
+ day: 'numeric',
+ timeZone: 'UTC',
+ });
+ const monthLabel = date.toLocaleString('fullwide', {
+ month: 'long',
+ timeZone: 'UTC',
+ });
+ const yearLabel = date.toLocaleString('fullwide', {
+ year: 'numeric',
+ timeZone: 'UTC',
+ });
+ switch (selectedTimePeriod) {
+ case DateRange.DAILY:
+ timePeriodInfo.chartGroup = dateStr;
+ timePeriodInfo.chartGroupLabel = dayLabel;
+ timePeriodInfo.listGroup = dateStr;
+ timePeriodInfo.listGroupLabel = dayLabel;
+ break;
+ case DateRange.MONTHLY:
+ timePeriodInfo.chartGroup = `${newYear}-${newMonth}`;
+ timePeriodInfo.chartGroupLabel = monthLabel;
+ timePeriodInfo.listGroup = `${newYear}-${newMonth}`;
+ timePeriodInfo.listGroupLabel = monthLabel;
+ timePeriodInfo.listGroupHeader = newYear;
+ break;
+ case DateRange.YEARLY:
+ timePeriodInfo.chartGroup = newYear;
+ timePeriodInfo.chartGroupLabel = yearLabel;
+ timePeriodInfo.listGroup = newYear;
+ timePeriodInfo.listGroupLabel = yearLabel;
+ break;
+ default:
+ throw new Error('Unsupported time period');
+ }
+ return timePeriodInfo;
+};
+
+/**
+ * Fills gaps in earnings history by adding zero values for days missing out of the limitDays
+ *
+ * @param {EarningHistory[] | null} earningsHistory - The earnings history to fill gaps in.
+ * @param {number} limitDays - The number of days to fill gaps for.
+ * @returns {EarningHistory[]} The filled earnings history.
+ */
+export const fillGapsInEarningsHistory = (
+ earningsHistory: EarningHistory[] | null,
+ limitDays: number,
+): EarningHistory[] => {
+ if (!earningsHistory?.length) return [];
+ const gapFilledEarningsHistory = [...earningsHistory];
+ const earliestDate = new Date(earningsHistory[0].dateStr);
+ const daysToFill = limitDays - earningsHistory.length;
+ const gapDate = new Date(earliestDate);
+ gapDate.setUTCHours(0, 0, 0, 0);
+ for (let i = 0; i < daysToFill; i++) {
+ gapDate.setDate(gapDate.getDate() - 1);
+ gapFilledEarningsHistory.unshift({
+ dateStr: gapDate.toISOString().split('T')[0],
+ dailyRewards: '0',
+ sumRewards: '0',
+ });
+ }
+ return gapFilledEarningsHistory;
+};
+
+/**
+ * Formats the rewards value from minimal unit to string representation
+ *
+ * @param {number | string | BN} rewardsValue - The rewards value in minimal units.
+ * @param {TokenI} asset - The asset to format the rewards value for.
+ * @param {boolean} [isRemoveSpecialCharacters=false] - A flag indicating whether to remove special characters from the formatted output.
+ * @returns {string} The formatted rewards value as a string.
+ */
+export const formatRewardsWei = (
+ rewardsValue: number | string | BN,
+ asset: TokenI,
+ isRemoveSpecialCharacters: boolean = false,
+): string => {
+ if (!isRemoveSpecialCharacters) {
+ // return a string with possible special characters in display formatting
+ return asset.isETH
+ ? renderFromWei(rewardsValue)
+ : renderFromTokenMinimalUnit(rewardsValue, asset.decimals);
+ }
+ // return a string without special characters
+ return asset.isETH
+ ? fromWei(rewardsValue)
+ : fromTokenMinimalUnit(rewardsValue, asset.decimals);
+};
+
+/**
+ * Formats floating point number rewards value into a string representation
+ *
+ * @param {number} rewardsValue - The raw rewards value to format.
+ * @param {TokenI} asset - The asset to format the rewards value for.
+ * @returns {string} The formatted rewards value string.
+ */
+export const formatRewardsNumber = (
+ rewardsValue: number,
+ asset: TokenI,
+): string => {
+ const weiValue = new BN(
+ new BigNumber(rewardsValue)
+ .multipliedBy(new BigNumber(10).pow(asset.decimals))
+ .toString(),
+ );
+ return formatRewardsWei(weiValue, asset);
+};
+
+/**
+ * Formats the rewards amount into a fiat currency representation.
+ *
+ * @param {string | BN} rewardsValue - The amount of rewards to format in minimal units, which can be a string or a BigNumber (BN).
+ * @param {TokenI} asset - The asset to format the rewards value for.
+ * @param {string} currency - The currency symbol to convert to.
+ * @param {number} conversionRate - ETH to current currency conversion rate
+ * @param {number} exchangeRate - Asset to ETH conversion rate.
+ * @returns {string} The formatted fiat currency string.
+ */
+export const formatRewardsFiat = (
+ rewardsValue: string | BN,
+ asset: TokenI,
+ currency: string,
+ conversionRate: number,
+ exchangeRate: number = 0,
+): string => {
+ if (asset.isETH) {
+ const weiFiatNumber = weiToFiatNumber(new BN(rewardsValue), conversionRate);
+ return renderFiat(weiFiatNumber, currency, 2);
+ }
+ const balanceFiatNumber = balanceToFiatNumber(
+ renderFromTokenMinimalUnit(rewardsValue, asset.decimals),
+ conversionRate,
+ exchangeRate,
+ );
+ return renderFiat(balanceFiatNumber, currency, 2);
+};
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts
new file mode 100644
index 00000000000..2c46ad4d0aa
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.styles.ts
@@ -0,0 +1,25 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ stakingEarningsHistoryChart: {
+ height: 75,
+ flex: 1,
+ paddingTop: 16,
+ paddingBottom: 16,
+ marginBottom: 4,
+ },
+ stakingEarningsHistoryChartHeaderContainer: {
+ flex: 1, // Use flex to fill the available space
+ justifyContent: 'center', // Center vertically
+ alignItems: 'center', // Center horizontally
+ paddingTop: 16,
+ paddingBottom: 16,
+ },
+ stakingEarningsHistoryChartContainer: {
+ flex: 1,
+ width: '100%',
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx
new file mode 100644
index 00000000000..36cb7e84c9f
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.test.tsx
@@ -0,0 +1,202 @@
+import React from 'react';
+import { fireEvent, render, RenderResult } from '@testing-library/react-native';
+import { StakingEarningsHistoryChart } from './StakingEarningsHistoryChart';
+import { fireLayoutEvent } from '../../../../../../../util/testUtils/react-native-svg-charts';
+
+jest.mock('react-native-svg-charts', () => {
+ const reactNativeSvgCharts = jest.requireActual('react-native-svg-charts'); // Get the actual Grid component
+ return {
+ ...reactNativeSvgCharts,
+ Grid: () => <>>,
+ };
+});
+
+const mockEarningsData = {
+ earnings: [
+ { value: 1.0, label: 'Day 1' },
+ { value: 3.0, label: 'Day 2' },
+ { value: 2.0, label: 'Day 3' },
+ ],
+ earningsTotal: '6.00000',
+ ticker: 'ETH',
+};
+
+const barChartComponent = (
+
+);
+
+const renderChart = () => {
+ const chartContainer = render(barChartComponent);
+ fireLayoutEvent(chartContainer.root, { width: 500, height: 200 });
+ const chart = chartContainer.getByTestId('earnings-history-chart-container')
+ .props.children.props.children[1].props.children.props;
+ return {
+ chartContainer,
+ chart,
+ };
+};
+
+describe('StakingEarningsHistoryChart', () => {
+ let chartContainer: RenderResult;
+ let chart: RenderResult['root'];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ ({ chartContainer, chart } = renderChart());
+ });
+
+ it('renders correct initial state', async () => {
+ expect(chartContainer.getByText('Lifetime earnings')).toBeTruthy();
+ expect(chartContainer.getByText('6.00000 ETH')).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-bar-0'),
+ ).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-bar-1'),
+ ).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-bar-2'),
+ ).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-line-0'),
+ ).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-line-1'),
+ ).toBeTruthy();
+ expect(
+ chartContainer.getByTestId('earning-history-chart-line-2'),
+ ).toBeTruthy();
+ expect(chart.data.length).toBe(mockEarningsData.earnings.length);
+ });
+
+ it('updates chart state when bar 1 is clicked', () => {
+ // click bar 1
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchStart',
+ {
+ nativeEvent: { locationX: 50 },
+ },
+ );
+ // expect bar 1 to be selected and highlighted on touch
+ expect(chartContainer.getByText('Day 1')).toBeTruthy();
+ expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy();
+ expect(chart.data[0].svg.fill).toBe('#1c8234');
+ // end touch bar 1
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchEnd',
+ {
+ nativeEvent: { pageX: 50, pageY: 50 },
+ },
+ );
+ // expect bar 1 to be selected and highlighted after touch end
+ expect(chartContainer.getByText('Day 1')).toBeTruthy();
+ expect(chartContainer.getByText('1.00000 ETH')).toBeTruthy();
+ expect(chart.data[0].svg.fill).toBe('#1c8234');
+ });
+
+ it('updates chart state when bar 2 is clicked', async () => {
+ // click bar 2
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchStart',
+ {
+ nativeEvent: { locationX: 250 },
+ },
+ );
+ // expect bar 2 to be selected and highlighted on touch
+ expect(chartContainer.getByText('Day 2')).toBeTruthy();
+ expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy();
+ expect(chart.data[1].svg.fill).toBe('#1c8234');
+ // end touch bar 2
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchEnd',
+ {
+ nativeEvent: { locationX: 250 },
+ },
+ );
+ // expect bar 2 to be selected and highlighted after touch end
+ expect(chartContainer.getByText('Day 2')).toBeTruthy();
+ expect(chartContainer.getByText('3.00000 ETH')).toBeTruthy();
+ expect(chart.data[1].svg.fill).toBe('#1c8234');
+ });
+
+ it('updates chart state when bar 3 is clicked', async () => {
+ // click bar 3
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchStart',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ expect(chartContainer.getByText('Day 3')).toBeTruthy();
+ expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy();
+ expect(chart.data[2].svg.fill).toBe('#1c8234');
+ // end touch bar 3
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchEnd',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ expect(chartContainer.getByText('Day 3')).toBeTruthy();
+ expect(chartContainer.getByText('2.00000 ETH')).toBeTruthy();
+ expect(chart.data[2].svg.fill).toBe('#1c8234');
+ });
+
+ it('updates chart to initial state when selected bar is set unselected', async () => {
+ // click bar 3
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchStart',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ // end touch bar 3
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchEnd',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ // expect bar 3 to be selected and highlighted
+ expect(chart.data[2].svg.fill).toBe('#1c8234');
+ // click again
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchStart',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ // end touch bar 3
+ fireEvent(
+ chartContainer.getByTestId('earnings-history-chart'),
+ 'onTouchEnd',
+ {
+ nativeEvent: { locationX: 450 },
+ },
+ );
+ // expect bar 3 to be unselected and not highlighted
+ expect(chart.data[0].svg.fill).toBe('url(#bar-gradient)');
+ expect(chart.data[1].svg.fill).toBe('url(#bar-gradient)');
+ expect(chart.data[2].svg.fill).toBe('url(#bar-gradient)');
+ // expect chart to be in initial state
+ expect(chartContainer.getByText('Lifetime earnings')).toBeTruthy();
+ expect(chartContainer.getByText('6.00000 ETH')).toBeTruthy();
+ });
+
+ it('renders to match snapshot', () => {
+ expect(chartContainer.toJSON()).toMatchSnapshot();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx
new file mode 100644
index 00000000000..1bc98f34392
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.tsx
@@ -0,0 +1,279 @@
+import React, { useCallback, useEffect, useState } from 'react';
+import { GestureResponderEvent, View, LayoutChangeEvent } from 'react-native';
+import { Defs, Line, LinearGradient, Stop } from 'react-native-svg';
+import { BarChart, Grid } from 'react-native-svg-charts';
+import { useStyles } from '../../../../../../../component-library/hooks';
+import { useTheme } from '../../../../../../../util/theme';
+import styleSheet from './StakingEarningsHistoryChart.styles';
+import Text, {
+ TextVariant,
+} from '../../../../../../../component-library/components/Texts/Text';
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
+import {
+ HorizontalLinesProps,
+ StakingEarningsHistoryChartProps,
+} from './StakingEarningsHistoryChart.types';
+
+const HorizontalLines = ({
+ x,
+ y,
+ height,
+ bandwidth: bandWidth,
+ data,
+ onBandWidthChange,
+ strokeColor,
+}: HorizontalLinesProps) => {
+ useEffect(() => {
+ onBandWidthChange && onBandWidthChange(bandWidth ?? 0);
+ }, [bandWidth, onBandWidthChange]);
+
+ const renderBarTopLines = useCallback(() => {
+ if (!x || !y || !height || !data || !bandWidth) return null;
+
+ return data.map((item, index) => (
+
+ ));
+ }, [data, x, y, height, bandWidth, strokeColor]);
+
+ return <>{renderBarTopLines()}>;
+};
+
+export function StakingEarningsHistoryChart({
+ earnings,
+ ticker,
+ earningsTotal,
+ formatValue = (value) => value.toFixed(5),
+ onSelectedEarning,
+}: StakingEarningsHistoryChartProps): React.ReactElement {
+ //hooks
+ const { colors } = useTheme();
+ const { styles } = useStyles(styleSheet, {});
+
+ // constants
+ const animate = false;
+ const barGradientId = 'bar-gradient';
+ const barGradientStop1 = {
+ offset: '0%',
+ stopColor: colors.success.muted,
+ stopOpacity: 0,
+ };
+ const barGradientStop2 = {
+ offset: '100%',
+ stopColor: colors.success.muted,
+ stopOpacity: 0.1,
+ };
+ const spacingDefault = 0;
+
+ //states
+ const [selectedBarAmount, setSelectedBarAmount] = useState(
+ null,
+ );
+ const [lastOnSelectedEarningBarIndex, setLastOnSelectedEarningBarIndex] =
+ useState(-1);
+ const [lastToggledBarIndex, setLastToggledBarIndex] = useState(-1);
+ const [barToggle, setBarToggle] = useState(false);
+ const [selectedBarIndex, setSelectedBarIndex] = useState(-1);
+ const [selectedBarLabel, setSelectedBarLabel] = useState(null);
+ const [bandWidth, setBandWidth] = useState(0);
+ const [spacing, setSpacing] = useState(spacingDefault);
+ const [chartWidth, setChartWidth] = useState(0);
+ const [hoveredBarIndex, setHoveredBarIndex] = useState(-1);
+ const [transformedData, setTransformedData] = useState<
+ {
+ value: number;
+ label: string;
+ svg: { fill: string; testID: string };
+ }[]
+ >([]);
+
+ // functions
+ const updateBarHoveredBarIndex = useCallback(
+ (xHover: number) => {
+ if (!bandWidth || !chartWidth || !earnings.length) return;
+ const barWidthTotal = bandWidth * earnings.length;
+ const spacingTotal = chartWidth - barWidthTotal;
+ const estimateGapSize = spacingTotal
+ ? spacingTotal / (earnings.length - 1)
+ : 0;
+ const barSegment = Math.floor(xHover / (bandWidth + estimateGapSize));
+ if (barSegment >= 0 && barSegment < earnings.length) {
+ setHoveredBarIndex(barSegment);
+ } else {
+ setHoveredBarIndex(-1);
+ }
+ },
+ [bandWidth, chartWidth, earnings.length],
+ );
+ const handleTouchEnd = () => {
+ setHoveredBarIndex(-1);
+ let overrideBarToggle = !!barToggle;
+ if (lastToggledBarIndex !== selectedBarIndex) {
+ overrideBarToggle = false;
+ }
+ if (!overrideBarToggle) {
+ setBarToggle(true);
+ setLastToggledBarIndex(selectedBarIndex);
+ } else {
+ setBarToggle(false);
+ if (
+ lastToggledBarIndex !== -1 &&
+ lastToggledBarIndex === selectedBarIndex
+ ) {
+ setSelectedBarIndex(-1);
+ setLastToggledBarIndex(-1);
+ }
+ }
+ };
+ const handleTouch = (evt: GestureResponderEvent) => {
+ updateBarHoveredBarIndex(evt.nativeEvent.locationX);
+ };
+
+ // update bar fill color on index change
+ useEffect(() => {
+ setTransformedData((prev) => {
+ const newTransformedData = [...prev];
+ newTransformedData.forEach((data, index) => {
+ if (index === selectedBarIndex) {
+ data.svg.fill = colors.success.default;
+ } else {
+ data.svg.fill = `url(#${barGradientId})`;
+ }
+ });
+ return newTransformedData;
+ });
+ }, [selectedBarIndex, colors.success.default]);
+ // if there is graph data or width change update all state
+ useEffect(() => {
+ if (earnings && earnings.length > 0) {
+ let newSpacing = spacingDefault;
+ if (earnings.length > 1) {
+ newSpacing = 0.1;
+ }
+ setSpacing(newSpacing);
+ const newTransformedData = earnings.map((value, index) => ({
+ value: value.value,
+ label: value.label,
+ svg: {
+ fill: `url(#${barGradientId})`,
+ testID: `earning-history-chart-bar-${index}`,
+ },
+ }));
+ setTransformedData(newTransformedData);
+ }
+ }, [earnings, chartWidth]);
+ // select what is hovered over
+ useEffect(() => {
+ if (hoveredBarIndex !== -1) {
+ setSelectedBarIndex(hoveredBarIndex);
+ }
+ }, [hoveredBarIndex]);
+ // main updates, time period earnings change, selected bar change
+ useEffect(() => {
+ if (selectedBarIndex !== -1 && selectedBarIndex < earnings.length) {
+ const newSelectedBarAmount = formatValue(
+ earnings[selectedBarIndex].value,
+ );
+ const newSelectedBarLabel = earnings[selectedBarIndex].label;
+ setSelectedBarAmount(newSelectedBarAmount);
+ setSelectedBarLabel(newSelectedBarLabel);
+ } else {
+ setSelectedBarAmount(null);
+ setSelectedBarLabel(null);
+ }
+ if (
+ onSelectedEarning &&
+ lastOnSelectedEarningBarIndex !== selectedBarIndex
+ ) {
+ onSelectedEarning(earnings[selectedBarIndex]);
+ setLastOnSelectedEarningBarIndex(selectedBarIndex);
+ }
+ }, [
+ selectedBarIndex,
+ earnings,
+ formatValue,
+ colors.success.default,
+ onSelectedEarning,
+ lastToggledBarIndex,
+ lastOnSelectedEarningBarIndex,
+ ]);
+ // reset bar toggle state when earnings array changes
+ useEffect(() => {
+ // deselect all bars each change
+ setSelectedBarIndex(-1);
+ setLastToggledBarIndex(-1);
+ setBarToggle(false);
+ }, [earnings]);
+
+ return earnings ? (
+ {
+ const { width } = event.nativeEvent.layout;
+ setChartWidth(width);
+ }}
+ testID={'earnings-history-chart-container'}
+ >
+
+
+
+ {selectedBarAmount ?? earningsTotal} {ticker}
+
+
+ {selectedBarLabel ?? `Lifetime earnings`}
+
+
+
+ item.value}
+ spacingInner={spacing}
+ spacingOuter={0}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+ ) : (
+
+
+
+ );
+}
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts
new file mode 100644
index 00000000000..62ee051d95b
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/StakingEarningsHistoryChart.types.ts
@@ -0,0 +1,26 @@
+export interface StakingEarningsHistoryChartData {
+ value: number;
+ label: string;
+}
+
+export interface StakingEarningsHistoryChartProps {
+ earnings: StakingEarningsHistoryChartData[];
+ ticker: string;
+ earningsTotal: string;
+ // callback to handle selected earning
+ onSelectedEarning?: (earning?: { value: number; label: string }) => void;
+ // format the graph value from parent
+ formatValue?: (value: number) => string;
+}
+
+export interface HorizontalLinesProps {
+ // sends bandwidth to parent
+ onBandWidthChange?: (bandWidth: number) => void;
+ strokeColor: string;
+ // BarChart component props are passed into all children
+ x?: (number: number) => number;
+ y?: (number: number) => number;
+ height?: number;
+ bandwidth?: number;
+ data?: StakingEarningsHistoryChartProps['earnings'];
+}
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap
new file mode 100644
index 00000000000..0f1c7f2cf19
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryChart/__snapshots__/StakingEarningsHistoryChart.test.tsx.snap
@@ -0,0 +1,265 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`StakingEarningsHistoryChart renders to match snapshot 1`] = `
+
+
+
+
+ 6.00000
+
+ ETH
+
+
+ Lifetime earnings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts
new file mode 100644
index 00000000000..50277e0c15c
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.styles.ts
@@ -0,0 +1,40 @@
+import { StyleSheet } from 'react-native';
+
+const styleSheet = () =>
+ StyleSheet.create({
+ stakingEarningsHistoryListContainer: {
+ flex: 1,
+ paddingTop: 24,
+ paddingBottom: 35,
+ },
+ lineItemContainer: {
+ flex: 1,
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ paddingVertical: 8,
+ },
+ rightLineItemContainer: {
+ flex: 1,
+ width: '50%',
+ justifyContent: 'space-between',
+ alignItems: 'flex-end',
+ },
+ rightLineItemBox: {
+ flex: 1,
+ },
+ leftLineItemBox: {
+ width: '50%',
+ justifyContent: 'center',
+ alignItems: 'flex-start',
+ flex: 1,
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ lineItemGroupHeaderContainer: {
+ paddingTop: 10,
+ paddingBottom: 10,
+ },
+ });
+
+export default styleSheet;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx
new file mode 100644
index 00000000000..7b32c416bf8
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.test.tsx
@@ -0,0 +1,47 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import StakingEarningsHistoryList from './StakingEarningsHistoryList';
+
+describe('StakingEarningsHistoryList', () => {
+ const mockEarnings = [
+ {
+ label: 'Reward 1',
+ amount: '1.0',
+ amountSecondaryText: '$3000.00',
+ groupLabel: 'Daily',
+ groupHeader: '2024',
+ ticker: 'ETH',
+ },
+ {
+ label: 'Reward 2',
+ amount: '3.0',
+ amountSecondaryText: '$5000.00',
+ groupLabel: 'Daily',
+ groupHeader: '2024',
+ ticker: 'ETH',
+ },
+ {
+ label: 'Reward 3',
+ amount: '2.0',
+ amountSecondaryText: '$6000.00',
+ groupLabel: 'Daily',
+ groupHeader: '2025',
+ ticker: 'ETH',
+ },
+ ];
+
+ it('renders correctly with earnings data', () => {
+ const { getByText, getAllByText } = render(
+ ,
+ );
+
+ // Check if each earning is displayed
+ mockEarnings.forEach((earning) => {
+ expect(getByText(earning.label)).toBeTruthy();
+ expect(getByText(`+ ${earning.amount} ETH`)).toBeTruthy();
+ expect(getByText(earning.amountSecondaryText)).toBeTruthy();
+ expect(getAllByText(`2024`)).toHaveLength(1);
+ expect(getAllByText(`2025`)).toHaveLength(1);
+ });
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx
new file mode 100644
index 00000000000..14617fccf6d
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.tsx
@@ -0,0 +1,107 @@
+import React, { useCallback } from 'react';
+import { View } from 'react-native';
+import Label from '../../../../../../../component-library/components/Form/Label';
+import Text from '../../../../../../../component-library/components/Texts/Text';
+import { TextVariant } from '../../../../../../../component-library/components/Texts/Text/Text.types';
+import { useTheme } from '../../../../../../../util/theme';
+import styleSheet from './StakingEarningsHistoryList.styles';
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
+import { strings } from '../../../../../../../../locales/i18n';
+import { StakingEarningsHistoryListProps } from './StakingEarningsHistoryList.types';
+
+const StakingEarningsHistoryList = ({
+ earnings,
+ filterByGroupLabel,
+}: StakingEarningsHistoryListProps) => {
+ const { colors } = useTheme();
+ const styles = styleSheet();
+
+ const renderEarningsList = useCallback(() => {
+ let lastGroupHeader: string | null = null;
+ return earnings.map((earning, index) => {
+ const isFirstEarningInGroup = earning.groupHeader !== lastGroupHeader;
+ lastGroupHeader = earning.groupHeader;
+ const isGroupHeaderVisible =
+ earning.groupHeader.length > 0 && isFirstEarningInGroup;
+ if (!filterByGroupLabel || earning.groupLabel === filterByGroupLabel) {
+ return (
+
+ {isGroupHeaderVisible && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ {earning.amountSecondaryText}
+
+
+
+
+
+ );
+ }
+ return null;
+ });
+ }, [earnings, filterByGroupLabel, styles, colors]);
+
+ const renderLoadingSkeleton = useCallback(
+ () => (
+
+ {Array.from({ length: 7 }).map((_, index) => (
+
+ ))}
+
+ ),
+ [],
+ );
+
+ return (
+
+ {earnings ? (
+ <>
+
+ {renderEarningsList()}
+ >
+ ) : (
+ renderLoadingSkeleton()
+ )}
+
+ );
+};
+
+export default StakingEarningsHistoryList;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts
new file mode 100644
index 00000000000..acea05cbbe7
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsHistoryList/StakingEarningsHistoryList.types.ts
@@ -0,0 +1,13 @@
+export interface StakingEarningsHistoryListProps {
+ earnings: StakingEarningsHistoryListData[];
+ filterByGroupLabel?: string;
+}
+
+export interface StakingEarningsHistoryListData {
+ label: string;
+ amount: string;
+ amountSecondaryText: string;
+ groupLabel: string;
+ groupHeader: string;
+ ticker: string;
+}
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts
new file mode 100644
index 00000000000..0c46050b59c
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.styles.ts
@@ -0,0 +1,43 @@
+import { StyleSheet } from 'react-native';
+import { Theme } from '../../../../../../../util/theme/models';
+
+const styleSheet = (params: { theme: Theme }) => {
+ const { theme } = params;
+ const { colors } = theme;
+
+ return StyleSheet.create({
+ timePeriodButtonGroupContainer: {
+ flexDirection: 'row',
+ justifyContent: 'center',
+ paddingTop: 32,
+ paddingBottom: 32,
+ },
+ unselectedButtonLabel: {
+ color: colors.text.alternative,
+ },
+ selectedButtonLabel: {
+ color: colors.text.alternative,
+ },
+ buttonContainer: {
+ marginLeft: 8,
+ marginRight: 8,
+ },
+ button: {
+ backgroundColor: colors.background.default,
+ width: '100%',
+ borderRadius: 32,
+ paddingHorizontal: 14,
+ paddingVertical: 7,
+ },
+ selectedButton: {
+ backgroundColor: colors.background.muted,
+ },
+ buttonLabel: {
+ letterSpacing: 3,
+ textAlign: 'center',
+ color: colors.text.default,
+ },
+ });
+};
+
+export default styleSheet;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx
new file mode 100644
index 00000000000..4c490eec6e3
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.test.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import { render, fireEvent } from '@testing-library/react-native';
+import TimePeriodButtonGroup from './StakingEarningsTimePeriod';
+import { DateRange } from './StakingEarningsTimePeriod.types';
+describe('TimePeriodButtonGroup', () => {
+ const mockOnTimePeriodChange = jest.fn();
+
+ it('renders correctly and allows time period selection', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ // Check if the initial time period button is rendered
+ expect(getByText('M')).toBeTruthy();
+
+ // Simulate selecting a different time period
+ fireEvent.press(getByText('7D'));
+
+ // Check if the onTimePeriodChange function is called with the correct argument
+ expect(mockOnTimePeriodChange).toHaveBeenCalledWith(DateRange.DAILY);
+ });
+
+ it('renders all time period options', () => {
+ const { getByText } = render(
+ ,
+ );
+
+ // Check if all time period buttons are rendered
+ expect(getByText('7D')).toBeTruthy();
+ expect(getByText('M')).toBeTruthy();
+ expect(getByText('Y')).toBeTruthy();
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx
new file mode 100644
index 00000000000..972e11870b2
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.tsx
@@ -0,0 +1,85 @@
+import React, { useState } from 'react';
+import { TouchableOpacity, View } from 'react-native';
+import SkeletonPlaceholder from 'react-native-skeleton-placeholder';
+import Text, {
+ TextVariant,
+} from '../../../../../../../component-library/components/Texts/Text';
+import { useStyles } from '../../../../../../../component-library/hooks';
+import styleSheet from './StakingEarningsTimePeriod.styles';
+import {
+ DateRange,
+ TimePeriodButtonGroupProps,
+} from './StakingEarningsTimePeriod.types';
+
+const TimePeriodButtonGroup: React.FC = ({
+ onTimePeriodChange = () => undefined,
+ initialTimePeriod,
+}) => {
+ const [selectedButton, setSelectedButton] = useState(
+ () => initialTimePeriod,
+ );
+ const [pressedButton, setPressedButton] = useState(null);
+
+ const { styles } = useStyles(styleSheet, {});
+
+ const renderButton = (dateRange: DateRange, width: number) => {
+ const handlePress = () => {
+ setSelectedButton(dateRange);
+ onTimePeriodChange(dateRange);
+ };
+ const handlePressIn = () => {
+ setPressedButton(dateRange);
+ };
+ const handlePressOut = () => {
+ setPressedButton(null);
+ };
+
+ const isSelected = selectedButton === dateRange;
+ const isPressed = pressedButton === dateRange;
+ const labelStyle =
+ !isSelected && !isPressed
+ ? styles.unselectedButtonLabel
+ : styles.selectedButtonLabel;
+ const labelElement = (
+
+ {dateRange}
+
+ );
+
+ const buttonSelectedStyle = !isSelected ? {} : styles.selectedButton;
+ const buttonStyle = { ...styles.button, ...buttonSelectedStyle };
+
+ return (
+
+
+
+ {labelElement}
+
+
+
+ );
+ };
+
+ return (
+
+ {initialTimePeriod ? (
+ <>
+ {renderButton(DateRange.DAILY, 50)}
+ {renderButton(DateRange.MONTHLY, 45)}
+ {renderButton(DateRange.YEARLY, 42)}
+ >
+ ) : (
+
+
+
+ )}
+
+ );
+};
+
+export default TimePeriodButtonGroup;
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts
new file mode 100644
index 00000000000..216fcddfd32
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistory/StakingEarningsTimePeriod/StakingEarningsTimePeriod.types.ts
@@ -0,0 +1,10 @@
+export enum DateRange {
+ DAILY = '7D',
+ MONTHLY = 'M',
+ YEARLY = 'Y',
+}
+
+export interface TimePeriodButtonGroupProps {
+ onTimePeriodChange?: (timePeriod: DateRange) => void;
+ initialTimePeriod: DateRange;
+}
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx
new file mode 100644
index 00000000000..abeeb275305
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.test.tsx
@@ -0,0 +1,52 @@
+import React from 'react';
+import { fireEvent, waitFor } from '@testing-library/react-native';
+import { WalletViewSelectorsIDs } from '../../../../../../../e2e/selectors/wallet/WalletView.selectors';
+import Routes from '../../../../../../constants/navigation/Routes';
+import renderWithProvider from '../../../../../../util/test/renderWithProvider';
+import { MOCK_STAKED_ETH_ASSET } from '../../../__mocks__/mockData';
+import StakingEarningsHistoryButton from './StakingEarningsHistoryButton';
+
+const mockNavigate = jest.fn();
+
+jest.mock('@react-navigation/native', () => {
+ const actualReactNavigation = jest.requireActual('@react-navigation/native');
+ return {
+ ...actualReactNavigation,
+ useNavigation: () => ({
+ navigate: mockNavigate,
+ }),
+ };
+});
+
+const renderComponent = () =>
+ renderWithProvider(
+ ,
+ );
+
+describe('StakingEarningsHistoryButton', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it('renders correctly', () => {
+ const { getByTestId } = renderComponent();
+ expect(
+ getByTestId(WalletViewSelectorsIDs.STAKE_EARNINGS_HISTORY_BUTTON),
+ ).toBeDefined();
+ });
+
+ it('navigates to Stake Rewards History screen when stake history button is pressed', async () => {
+ const { getByTestId } = renderComponent();
+
+ fireEvent.press(
+ getByTestId(WalletViewSelectorsIDs.STAKE_EARNINGS_HISTORY_BUTTON),
+ );
+
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('StakeScreens', {
+ screen: Routes.STAKING.EARNINGS_HISTORY,
+ params: { asset: MOCK_STAKED_ETH_ASSET },
+ });
+ });
+ });
+});
diff --git a/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx
new file mode 100644
index 00000000000..f8fe909fc8a
--- /dev/null
+++ b/app/components/UI/Stake/components/StakingEarnings/StakingEarningsHistoryButton/StakingEarningsHistoryButton.tsx
@@ -0,0 +1,42 @@
+import { useNavigation } from '@react-navigation/native';
+import React from 'react';
+import { View } from 'react-native';
+import { strings } from '../../../../../../../locales/i18n';
+import Button, {
+ ButtonVariants,
+ ButtonWidthTypes,
+} from '../../../../../../component-library/components/Buttons/Button';
+import Routes from '../../../../../../constants/navigation/Routes';
+import { TokenI } from '../../../../Tokens/types';
+import { WalletViewSelectorsIDs } from '../../../../../../../e2e/selectors/wallet/WalletView.selectors';
+
+interface StakingEarningsHistoryButtonProps {
+ asset: TokenI;
+}
+
+const StakingEarningsHistoryButton = ({
+ asset,
+}: StakingEarningsHistoryButtonProps) => {
+ const { navigate } = useNavigation();
+
+ const onViewEarningsHistoryPress = () => {
+ navigate('StakeScreens', {
+ screen: Routes.STAKING.EARNINGS_HISTORY,
+ params: { asset },
+ });
+ };
+
+ return (
+
+
+
+ );
+};
+
+export default StakingEarningsHistoryButton;
diff --git a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
index 17f64f7622b..04f815eafa4 100644
--- a/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
+++ b/app/components/UI/Stake/components/StakingEarnings/__snapshots__/StakingEarnings.test.tsx.snap
@@ -250,6 +250,47 @@ exports[`Staking Earnings should render correctly 1`] = `
+
+
+
+ View earnings history
+
+
+
`;
diff --git a/app/components/UI/Stake/components/StakingEarnings/index.tsx b/app/components/UI/Stake/components/StakingEarnings/index.tsx
index e7970ffc63d..b8b59efe3dc 100644
--- a/app/components/UI/Stake/components/StakingEarnings/index.tsx
+++ b/app/components/UI/Stake/components/StakingEarnings/index.tsx
@@ -24,6 +24,7 @@ import { withMetaMetrics } from '../../utils/metaMetrics/withMetaMetrics';
import { MetaMetricsEvents } from '../../../../hooks/useMetrics';
import { getTooltipMetricProperties } from '../../utils/metaMetrics/tooltipMetaMetricsUtils';
import { TokenI } from '../../../Tokens/types';
+import StakingEarningsHistoryButton from './StakingEarningsHistoryButton/StakingEarningsHistoryButton';
export interface StakingEarningsProps {
asset: TokenI;
@@ -178,6 +179,7 @@ const StakingEarningsContent = ({ asset }: StakingEarningsProps) => {
)}
+
);
diff --git a/app/components/UI/Stake/hooks/useStakingEarningsHistory.test.tsx b/app/components/UI/Stake/hooks/useStakingEarningsHistory.test.tsx
new file mode 100644
index 00000000000..dec8dd94dc5
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEarningsHistory.test.tsx
@@ -0,0 +1,98 @@
+import { ChainId } from '@metamask/controller-utils';
+import useStakingEarningsHistory from './useStakingEarningsHistory';
+import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
+import { StakingApiService, UserDailyReward } from '@metamask/stake-sdk';
+import { waitFor } from '@testing-library/react-native';
+import { backgroundState } from '../../../../util/test/initial-root-state';
+import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../../util/test/accountsControllerTestUtils';
+
+const mockInitialState = {
+ engine: {
+ backgroundState: {
+ ...backgroundState,
+ AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE,
+ },
+ },
+};
+
+afterEach(() => {
+ jest.clearAllMocks();
+});
+
+describe('useStakingEarningsHistory', () => {
+ it('should return loading state initially', async () => {
+ jest
+ .spyOn(StakingApiService.prototype, 'getUserDailyRewards')
+ .mockResolvedValue({
+ userRewards: [],
+ });
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useStakingEarningsHistory({
+ chainId: ChainId.mainnet,
+ limitDays: 365,
+ }),
+ {
+ state: mockInitialState,
+ },
+ );
+ expect(result.current.isLoading).toBe(true);
+ await waitFor(() => {
+ expect(result.current.isLoading).toBeFalsy();
+ });
+ });
+
+ it('should return error state if fetching fails', async () => {
+ jest
+ .spyOn(StakingApiService.prototype, 'getUserDailyRewards')
+ .mockImplementation(() => {
+ throw new Error('Fetch failed');
+ });
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useStakingEarningsHistory({
+ chainId: ChainId.mainnet,
+ limitDays: 365,
+ }),
+ {
+ state: mockInitialState,
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current.error).toBe('Failed to fetch earnings history');
+ });
+ });
+
+ it('should return earnings history when fetched successfully', async () => {
+ jest
+ .spyOn(StakingApiService.prototype, 'getUserDailyRewards')
+ .mockResolvedValue({
+ userRewards: [
+ {
+ dateStr: '2024-01-01',
+ dailyRewards: '100',
+ sumRewards: '100',
+ } as UserDailyReward,
+ ],
+ });
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useStakingEarningsHistory({
+ chainId: ChainId.mainnet,
+ limitDays: 365,
+ }),
+ {
+ state: mockInitialState,
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current?.earningsHistory).toBeInstanceOf(Array);
+ expect(result.current?.earningsHistory?.length).toBe(1);
+ });
+ });
+});
diff --git a/app/components/UI/Stake/hooks/useStakingEarningsHistory.ts b/app/components/UI/Stake/hooks/useStakingEarningsHistory.ts
new file mode 100644
index 00000000000..e911dd6eeb0
--- /dev/null
+++ b/app/components/UI/Stake/hooks/useStakingEarningsHistory.ts
@@ -0,0 +1,67 @@
+import { useCallback, useEffect, useState } from 'react';
+import { selectSelectedInternalAccountFormattedAddress } from '../../../../selectors/accountsController';
+import { useSelector } from 'react-redux';
+import { hexToNumber } from '@metamask/utils';
+import { ChainId } from '@metamask/controller-utils';
+import { StakingApiService } from '@metamask/stake-sdk';
+
+export interface EarningHistory {
+ sumRewards: string;
+ dateStr: string;
+ dailyRewards: string;
+}
+export interface EarningHistoryResponse {
+ userRewards: EarningHistory[];
+}
+
+const stakingApiService = new StakingApiService();
+
+const useStakingEarningsHistory = ({
+ chainId,
+ limitDays = 365,
+}: {
+ chainId: ChainId;
+ limitDays: number;
+}) => {
+ const numericChainId = hexToNumber(chainId);
+ const [earningsHistory, setEarningsHistory] = useState<
+ EarningHistory[] | null
+ >(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const selectedAddress =
+ useSelector(selectSelectedInternalAccountFormattedAddress) || '';
+ const fetchEarningsHistory = useCallback(async () => {
+ if (stakingApiService) {
+ setIsLoading(true);
+ setError(null);
+
+ try {
+ const earningHistoryResponse: EarningHistoryResponse =
+ await stakingApiService.getUserDailyRewards(
+ numericChainId,
+ selectedAddress,
+ limitDays,
+ );
+ setEarningsHistory(earningHistoryResponse.userRewards);
+ } catch (err) {
+ setError('Failed to fetch earnings history');
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ }, [numericChainId, selectedAddress, limitDays]);
+
+ useEffect(() => {
+ fetchEarningsHistory();
+ }, [fetchEarningsHistory]);
+
+ return {
+ earningsHistory,
+ isLoading,
+ error,
+ };
+};
+
+export default useStakingEarningsHistory;
diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx
index cf1ae55b3cb..84c323f42d9 100644
--- a/app/components/UI/Stake/routes/index.tsx
+++ b/app/components/UI/Stake/routes/index.tsx
@@ -9,6 +9,7 @@ import UnstakeConfirmationView from '../Views/UnstakeConfirmationView/UnstakeCon
import { StakeSDKProvider } from '../sdk/stakeSdkProvider';
import MaxInputModal from '../components/MaxInputModal';
import GasImpactModal from '../components/GasImpactModal';
+import StakeEarningsHistoryView from '../Views/StakeEarningsHistoryView/StakeEarningsHistoryView';
const Stack = createStackNavigator();
const ModalStack = createStackNavigator();
@@ -37,6 +38,10 @@ const StakeScreenStack = () => (
name={Routes.STAKING.UNSTAKE_CONFIRMATION}
component={UnstakeConfirmationView}
/>
+
);
diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx
index f4ab7f209b3..19faaa9d159 100644
--- a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx
+++ b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx
@@ -74,9 +74,14 @@ export const StakeSDKProvider: React.FC<
stakingApiService: sdkService?.stakingApiService,
sdkType,
setSdkType,
- networkClientId
+ networkClientId,
}),
- [sdkService?.pooledStakingContract, sdkService?.stakingApiService, sdkType, networkClientId],
+ [
+ sdkService?.pooledStakingContract,
+ sdkService?.stakingApiService,
+ sdkType,
+ networkClientId,
+ ],
);
return (
diff --git a/app/components/UI/Stake/utils/date/index.test.ts b/app/components/UI/Stake/utils/date/index.test.ts
new file mode 100644
index 00000000000..d55e4e8c3c1
--- /dev/null
+++ b/app/components/UI/Stake/utils/date/index.test.ts
@@ -0,0 +1,33 @@
+import { getUTCWeekRange } from '.';
+
+describe('date utils', () => {
+ describe('getUTCWeekRange', () => {
+ it('should return the correct week range during a month', () => {
+ const weekRange = getUTCWeekRange(new Date('2024-01-18'));
+ expect(weekRange.start).toBeDefined();
+ expect(weekRange.end).toBeDefined();
+ expect(weekRange.start).toBe('2024-01-15');
+ expect(weekRange.end).toBe('2024-01-21');
+ });
+
+ it('should return the correct week range across months', () => {
+ const weekRange = getUTCWeekRange(new Date('2024-01-31'));
+ expect(weekRange.start).toBeDefined();
+ expect(weekRange.end).toBeDefined();
+ expect(weekRange.start).toBe('2024-01-29');
+ expect(weekRange.end).toBe('2024-02-04');
+ });
+
+ it('should return the correct week range across years', () => {
+ const weekRange = getUTCWeekRange(new Date('2024-12-31'));
+ expect(weekRange.start).toBeDefined();
+ expect(weekRange.end).toBeDefined();
+ expect(weekRange.start).toBe('2024-12-30');
+ expect(weekRange.end).toBe('2025-01-05');
+ });
+
+ it('should error if the date is not a valid date', () => {
+ expect(() => getUTCWeekRange('not a date')).toThrow();
+ });
+ });
+});
diff --git a/app/components/UI/Stake/utils/date/index.ts b/app/components/UI/Stake/utils/date/index.ts
new file mode 100644
index 00000000000..3d6b87f7c4d
--- /dev/null
+++ b/app/components/UI/Stake/utils/date/index.ts
@@ -0,0 +1,14 @@
+export const getUTCWeekRange = (date: Date | string) => {
+ const startDate = new Date(date);
+ const dayOfWeek = startDate.getUTCDay();
+ const startOfWeek = new Date(startDate);
+ startOfWeek.setUTCDate(startDate.getUTCDate() - ((dayOfWeek + 6) % 7));
+ const endOfWeek = new Date(startOfWeek);
+ endOfWeek.setUTCDate(startOfWeek.getUTCDate() + 6);
+ // returns a formatted utc date yyyy-MM-dd
+ const formatDate = (d: Date) => d.toISOString().split('T')[0];
+ return {
+ start: formatDate(startOfWeek),
+ end: formatDate(endOfWeek),
+ };
+};
diff --git a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
index 2a809dd8e10..3d1d5a5234e 100644
--- a/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
+++ b/app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
@@ -14,6 +14,7 @@ import {
selectProviderConfig,
selectTicker,
selectNetworkConfigurations,
+ selectNetworkConfigurationByChainId,
} from '../../../../../selectors/networkController';
import {
selectContractExchangeRates,
@@ -26,7 +27,6 @@ import {
selectCurrentCurrency,
selectCurrencyRates,
} from '../../../../../selectors/currencyRateController';
-import { selectNetworkName } from '../../../../../selectors/networkInfos';
import { RootState } from '../../../../../reducers';
import { safeToChecksumAddress } from '../../../../../util/address';
import {
@@ -99,7 +99,9 @@ export const TokenListItem = React.memo(
ticker,
type,
);
- const networkName = useSelector(selectNetworkName);
+ const networkConfigurationByChainId = useSelector((state: RootState) =>
+ selectNetworkConfigurationByChainId(state, asset.chainId as Hex),
+ );
const primaryCurrency = useSelector(
(state: RootState) => state.settings.primaryCurrency,
);
@@ -327,7 +329,7 @@ export const TokenListItem = React.memo(
}
>
diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts
index ec146d9addc..7a38bbf048d 100644
--- a/app/constants/navigation/Routes.ts
+++ b/app/constants/navigation/Routes.ts
@@ -149,6 +149,7 @@ const Routes = {
STAKE_CONFIRMATION: 'StakeConfirmation',
UNSTAKE: 'Unstake',
UNSTAKE_CONFIRMATION: 'UnstakeConfirmation',
+ EARNINGS_HISTORY: 'EarningsHistory',
CLAIM: 'Claim',
MODALS: {
LEARN_MORE: 'LearnMore',
diff --git a/app/util/testUtils/react-native-svg-charts.ts b/app/util/testUtils/react-native-svg-charts.ts
new file mode 100644
index 00000000000..a12ccfded96
--- /dev/null
+++ b/app/util/testUtils/react-native-svg-charts.ts
@@ -0,0 +1,56 @@
+import { fireEvent, RenderResult } from '@testing-library/react-native';
+
+// https://github.com/JesperLekland/react-native-svg-charts/issues/418
+
+type Root = RenderResult['root'];
+
+const _findByProp = function (root: Root, prop = '', found: Root[] = []) {
+ if (!root) return found;
+
+ if (root.props) {
+ if (Object.keys(root.props).includes(prop)) found.push(root);
+ }
+
+ if (root.children?.length) {
+ root.children.forEach((c: Root) => _findByProp(c, prop, found));
+ }
+
+ return found;
+};
+
+// /**
+// * Find components in the given component hierarchy based
+// * on the name of one of their props. For example,
+// * `findByProp(root, 'foo')` will return a list of all
+// * components with a `foo` prop of any value.
+// */
+const findByProp = function (
+ /**
+ * The root of the component tree to search through.
+ */
+ root: Root,
+ /**
+ * The name of the prop you are using to select components.
+ */
+ prop = '',
+) {
+ return _findByProp(root, prop);
+};
+
+export const fireLayoutEvent = function (
+ /**
+ * The root node to search for components with `onLayout` props.
+ */
+ root: Root,
+ /**
+ * The event options inside of `event.nativeElement.layout`
+ */
+ options = {
+ width: 300,
+ height: 100,
+ },
+) {
+ findByProp(root, 'onLayout').forEach((n) =>
+ fireEvent(n, 'layout', { nativeEvent: { layout: options } }),
+ );
+};
diff --git a/e2e/selectors/wallet/WalletView.selectors.js b/e2e/selectors/wallet/WalletView.selectors.js
index ac561757ce6..84f6e3ff101 100644
--- a/e2e/selectors/wallet/WalletView.selectors.js
+++ b/e2e/selectors/wallet/WalletView.selectors.js
@@ -10,6 +10,7 @@ export const WalletViewSelectorsIDs = {
PORTFOLIO_BUTTON: 'portfolio-button',
TOTAL_BALANCE_TEXT: 'total-balance-text',
STAKE_BUTTON: 'stake-button',
+ STAKE_EARNINGS_HISTORY_BUTTON: 'stake-earnings-history-button',
IMPORT_NFT_BUTTON: 'import-collectible-button',
IMPORT_TOKEN_BUTTON: 'import-token-button',
IMPORT_TOKEN_BUTTON_LINK: 'import-token-button-link',
diff --git a/locales/languages/en.json b/locales/languages/en.json
index c53d9c643fe..9a93c08219b 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -3518,7 +3518,10 @@
"estimated_unstaking_time": "1 to 11 days",
"proceed_anyway": "Proceed anyway",
"gas_cost_impact": "Gas cost impact",
- "gas_cost_impact_warning": "Warning: the transaction gas cost will account for more than 30% of your deposit."
+ "gas_cost_impact_warning": "Warning: the transaction gas cost will account for more than {{percentOverDeposit}}% of your deposit.",
+ "view_earnings_history": "View earnings history",
+ "earnings_history_title": "{{ticker}} earnings",
+ "earnings_history_list_title": "Payout history"
},
"default_settings": {
"title": "Your Wallet is ready",
diff --git a/package.json b/package.json
index 775179d4c33..b1411fc1318 100644
--- a/package.json
+++ b/package.json
@@ -207,7 +207,7 @@
"@metamask/snaps-sdk": "^6.13.0",
"@metamask/snaps-utils": "^8.6.1",
"@metamask/solana-wallet-snap": "^1.2.0",
- "@metamask/stake-sdk": "^0.3.0",
+ "@metamask/stake-sdk": "^0.6.0",
"@metamask/swappable-obj-proxy": "^2.1.0",
"@metamask/swaps-controller": "^12.0.0",
"@metamask/transaction-controller": "^43.0.0",
diff --git a/yarn.lock b/yarn.lock
index 651c62ce858..fce91a385e7 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -5543,10 +5543,10 @@
resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-1.2.0.tgz#7d0db28a58cbd728306427144039d28f56f6d477"
integrity sha512-XG1NzrJu2Xvo6PKr5f3Ij6ojEBNGh1H/2WJCcCMSoKZUEl8UG5i8rmZ+SVcZJ0Jhr88sbredNXlAzqGESz2VBA==
-"@metamask/stake-sdk@^0.3.0":
- version "0.3.0"
- resolved "https://registry.yarnpkg.com/@metamask/stake-sdk/-/stake-sdk-0.3.0.tgz#b6156fd29666bfb9482a89b555cfe70b2b63769a"
- integrity sha512-i0QkzEc/7HRpVJbhe+qPORprQaMsuzRBpSgFjKgxbsLhz4YtYVDTgVJ5kesZXNy2KfprYTfeh4oV/jenQWgNJA==
+"@metamask/stake-sdk@^0.6.0":
+ version "0.6.0"
+ resolved "https://registry.npmjs.org/@metamask/stake-sdk/-/stake-sdk-0.6.0.tgz#f147debfc0179de4ff3814a406bcbc0144f432fa"
+ integrity sha512-y8NUie7yrMTqTOk4QStV2RJnRKJQ/B2I7wj/eSuH5naIivFuknunyiRKyUs5U4kPlTwCiNXaY+JytISjIxC+7w==
"@metamask/superstruct@^3.1.0":
version "3.1.0"