diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx index 10ac002beb1..9648fba601e 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.test.tsx @@ -5,6 +5,7 @@ import { renderScreen } from '../../../../../util/test/renderWithProvider'; import Routes from '../../../../../constants/navigation/Routes'; import { backgroundState } from '../../../../../util/test/initial-root-state'; import { BN } from 'ethereumjs-util'; +import { Stake } from '../../sdk/stakeSdkProvider'; function render(Component: React.ComponentType) { return renderScreen( @@ -51,6 +52,17 @@ jest.mock('../../../../../selectors/currencyRateController.ts', () => ({ })); const mockBalanceBN = new BN('1500000000000000000'); + +jest.mock('../../hooks/useStakeContext.ts', () => ({ + useStakeContext: jest.fn(() => { + const stakeContext: Stake = { + setSdkType: jest.fn(), + sdkService: undefined + } + return stakeContext + }) +})) + jest.mock('../../hooks/useBalance', () => ({ __esModule: true, default: () => ({ diff --git a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx index 0431e67a77f..782e4d5ab3c 100644 --- a/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx +++ b/app/components/UI/Stake/Views/StakeInputView/StakeInputView.tsx @@ -19,6 +19,7 @@ import styleSheet from './StakeInputView.styles'; import useStakingInputHandlers from '../../hooks/useStakingInput'; import useBalance from '../../hooks/useBalance'; import InputDisplay from '../../components/InputDisplay'; +import { useStakeContext } from '../../hooks/useStakeContext'; const StakeInputView = () => { const title = strings('stake.stake_eth'); @@ -43,6 +44,9 @@ const StakeInputView = () => { estimatedAnnualRewards, } = useStakingInputHandlers(balanceWei); + + const { sdkService } = useStakeContext(); + const navigateToLearnMoreModal = () => { navigation.navigate('StakeModals', { screen: Routes.STAKING.MODALS.LEARN_MORE, @@ -57,7 +61,8 @@ const StakeInputView = () => { amountFiat: fiatAmount, }, }); - }, [amountWei, fiatAmount, navigation]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [amountWei, fiatAmount, navigation, sdkService]); const balanceText = strings('stake.balance'); diff --git a/app/components/UI/Stake/hooks/useBalance.ts b/app/components/UI/Stake/hooks/useBalance.ts index cbe6db124d3..2ce1dfc6af4 100644 --- a/app/components/UI/Stake/hooks/useBalance.ts +++ b/app/components/UI/Stake/hooks/useBalance.ts @@ -48,7 +48,7 @@ const useBalance = () => { [balanceWei, conversionRate], ); - return { balance, balanceFiat, balanceWei, balanceFiatNumber }; + return { balance, balanceFiat, balanceWei, balanceFiatNumber, conversionRate, currentCurrency }; }; export default useBalance; diff --git a/app/components/UI/Stake/hooks/useStakeContext.ts b/app/components/UI/Stake/hooks/useStakeContext.ts new file mode 100644 index 00000000000..0fc280593da --- /dev/null +++ b/app/components/UI/Stake/hooks/useStakeContext.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { Stake, StakeContext } from '../sdk/stakeSdkProvider'; + +export const useStakeContext = () => { + const context = useContext(StakeContext); + return context as Stake; +}; diff --git a/app/components/UI/Stake/routes/index.tsx b/app/components/UI/Stake/routes/index.tsx index 14993705ca5..73960d4a7af 100644 --- a/app/components/UI/Stake/routes/index.tsx +++ b/app/components/UI/Stake/routes/index.tsx @@ -5,6 +5,7 @@ import LearnMoreModal from '../components/LearnMoreModal'; import Routes from '../../../../constants/navigation/Routes'; import StakeConfirmationView from '../Views/StakeConfirmationView/StakeConfirmationView'; import UnstakeInputView from '../Views/UnstakeInputView/UnstakeInputView'; +import { StakeSDKProvider } from '../sdk/stakeSdkProvider'; const Stack = createStackNavigator(); const ModalStack = createStackNavigator(); @@ -18,28 +19,35 @@ const clearStackNavigatorOptions = { // Regular Stack for Screens const StakeScreenStack = () => ( - - - - - + + + + + + + ); // Modal Stack for Modals const StakeModalStack = () => ( - - - + + + + + ); export { StakeScreenStack, StakeModalStack }; diff --git a/app/components/UI/Stake/sdk/UseSdkProvider.test.tsx b/app/components/UI/Stake/sdk/UseSdkProvider.test.tsx new file mode 100644 index 00000000000..6c47a20406c --- /dev/null +++ b/app/components/UI/Stake/sdk/UseSdkProvider.test.tsx @@ -0,0 +1,71 @@ +import { + ChainId, + PooledStakingContract, + StakingType, +} from '@metamask/stake-sdk'; +import renderWithProvider from '../../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../../util/test/initial-root-state'; +import { Stake } from '../sdk/stakeSdkProvider'; +// eslint-disable-next-line import/no-namespace +import * as useStakeContextHook from '../hooks/useStakeContext'; +import { Contract } from '@ethersproject/contracts'; +import { StakeModalStack, StakeScreenStack } from '../routes'; + +const mockPooledStakingContractService: PooledStakingContract = { + chainId: ChainId.ETHEREUM, + connectSignerOrProvider: jest.fn(), + contract: new Contract('0x0000000000000000000000000000000000000000', []), + convertToShares: jest.fn(), + encodeClaimExitedAssetsTransactionData: jest.fn(), + encodeDepositTransactionData: jest.fn(), + encodeEnterExitQueueTransactionData: jest.fn(), + encodeMulticallTransactionData: jest.fn(), + estimateClaimExitedAssetsGas: jest.fn(), + estimateDepositGas: jest.fn(), + estimateEnterExitQueueGas: jest.fn(), + estimateMulticallGas: jest.fn(), +}; + +const mockSDK: Stake = { + sdkService: mockPooledStakingContractService, + sdkType: StakingType.POOLED, + setSdkType: jest.fn(), +}; + +jest.mock('../../Stake/constants', () => ({ + isPooledStakingFeatureEnabled: jest.fn().mockReturnValue(true), +})); + +describe('Stake Modals With Stake Sdk Provider', () => { + const initialState = { + engine: { + backgroundState, + }, + }; + it('should render correctly stake screen with stake sdk provider and resolve the stake context', () => { + const useStakeContextSpy = jest + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); + + const { toJSON } = renderWithProvider(StakeScreenStack(), { + state: initialState, + }); + + expect(toJSON()).toMatchSnapshot(); + expect(useStakeContextSpy).toHaveBeenCalled(); + }); + + it('should render correctly stake modal with stake sdk provider and resolve the stake context', () => { + const useStakeContextSpy = jest + .spyOn(useStakeContextHook, 'useStakeContext') + .mockReturnValue(mockSDK); + + const { toJSON } = renderWithProvider(StakeModalStack(), { + state: initialState, + }); + + expect(toJSON()).toMatchSnapshot(); + expect(useStakeContextSpy).toHaveBeenCalledTimes(0); + + }); +}); diff --git a/app/components/UI/Stake/sdk/__snapshots__/UseSdkProvider.test.tsx.snap b/app/components/UI/Stake/sdk/__snapshots__/UseSdkProvider.test.tsx.snap new file mode 100644 index 00000000000..12944c1a6ce --- /dev/null +++ b/app/components/UI/Stake/sdk/__snapshots__/UseSdkProvider.test.tsx.snap @@ -0,0 +1,2224 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Stake Modals With Stake Sdk Provider should render correctly stake modal with stake sdk provider and resolve the stake context 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + Stake ETH and earn + + + + + Stake any amount of ETH + + + No minimum required. + + + + + Earn ETH rewards + + + Start earning as soon as you stake. Rewards compound automatically. + + + + + Flexible unstaking + + + Unstake anytime. Typically takes up to 11 days to process. + + + + + Staking does not guarantee rewards, and involves risks including a loss of funds. + + + + + + + + Learn more + + + + + + + Got it + + + + + + + + + + + + + + + + + + +`; + +exports[`Stake Modals With Stake Sdk Provider should render correctly stake screen with stake sdk provider and resolve the stake context 1`] = ` + + + + + + + + + + + + + Stake ETH + + + + + + Cancel + + + + + + + + + + + + + + + + + + + + + + + Balance + : + 0 ETH + + + + + 0 + + + ETH + + + + + + 0 USD + + + + + + + + + + + MetaMask Pool + + + + + + + + 2.6% + + + Estimated annual rewards + + + + + + + + + 25% + + + + + 50% + + + + + 75% + + + + + + Max + + + + + + + + 1 + + + + + 2 + + + + + 3 + + + + + + + 4 + + + + + 5 + + + + + 6 + + + + + + + 7 + + + + + 8 + + + + + 9 + + + + + + + . + + + + + 0 + + + + +  + + + + + + + + Enter amount + + + + + + + + + + + + + + + +`; diff --git a/app/components/UI/Stake/sdk/stakeSdkProvider.tsx b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx new file mode 100644 index 00000000000..19a6769949b --- /dev/null +++ b/app/components/UI/Stake/sdk/stakeSdkProvider.tsx @@ -0,0 +1,71 @@ +import { StakingType, StakeSdk, PooledStakingContract } from '@metamask/stake-sdk'; +import Logger from '../../../../util/Logger'; +import React, { + useState, + useEffect, + createContext, + useMemo, + PropsWithChildren, +} from 'react'; + +export const SDK = StakeSdk.create({ stakingType: StakingType.POOLED }); + +export interface Stake { + sdkError?: Error; + sdkService?: PooledStakingContract; // to do : facade it for other services implementation + + sdkType?: StakingType; + setSdkType: (stakeType: StakingType) => void; +} + +export const StakeContext = createContext(undefined); + +export interface StakeProviderProps { + stakingType?: StakingType; +} +export const StakeSDKProvider: React.FC> = ({ + children, +}) => { + const [sdkService, setSdkService] = useState(); + const [sdkError, setSdkError] = useState(); + const [sdkType, setSdkType] = useState(StakingType.POOLED); + + useEffect(() => { + (async () => { + try { + if (sdkType === StakingType?.POOLED) { + setSdkService(SDK.pooledStakingContractService); + } else { + const notImplementedError = new Error( + `StakeSDKProvider SDK.StakingType ${sdkType} not implemented yet`, + ); + Logger.error(notImplementedError); + setSdkError(notImplementedError); + } + } catch (error) { + Logger.error(error as Error, `StakeSDKProvider SDK.service failed`); + setSdkError(error as Error); + } + })(); + }, [sdkType]); + + const stakeContextValue = useMemo( + (): Stake => ({ + sdkError, + sdkService, + sdkType, + setSdkType, + }), + [ + sdkError, + sdkService, + sdkType, + setSdkType, + ], + ); + return ( + + {children} + + ); +}; \ No newline at end of file diff --git a/package.json b/package.json index 1b811292afe..64e61864089 100644 --- a/package.json +++ b/package.json @@ -183,6 +183,7 @@ "@metamask/snaps-rpc-methods": "^9.1.4", "@metamask/snaps-sdk": "^6.5.0", "@metamask/snaps-utils": "^8.1.1", + "@metamask/stake-sdk": "^0.2.11", "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/swaps-controller": "^9.0.12", "@metamask/transaction-controller": "^37.1.0", diff --git a/yarn.lock b/yarn.lock index 4d2b39ab497..1e3eb8b3669 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5532,6 +5532,13 @@ ses "^1.1.0" validate-npm-package-name "^5.0.0" +"@metamask/stake-sdk@^0.2.11": + version "0.2.11" + resolved "https://registry.yarnpkg.com/@metamask/stake-sdk/-/stake-sdk-0.2.11.tgz#70b003a7b7f5208fad0d5a986aedd84b0987979f" + integrity sha512-l2novyUK7oVKO2vZDd2tCSyQ8e468hWp0ZB3ed2FoR61HGlZDMqv3hDtXPAfOeA0+cQwsZM861yoUdSXNo0WPA== + dependencies: + axios "^1.7.7" + "@metamask/superstruct@^3.1.0": version "3.1.0" resolved "https://registry.yarnpkg.com/@metamask/superstruct/-/superstruct-3.1.0.tgz#148f786a674fba3ac885c1093ab718515bf7f648" @@ -12962,7 +12969,7 @@ axios-retry@^3.1.2: "@babel/runtime" "^7.15.4" is-retry-allowed "^2.2.0" -axios@1.4.0, axios@^0.26.0, axios@^0.28.0, axios@^0.x, axios@^1.6.7, axios@^1.6.8, axios@^1.7.4, axios@~1.6.8: +axios@1.4.0, axios@^0.26.0, axios@^0.28.0, axios@^0.x, axios@^1.6.7, axios@^1.6.8, axios@^1.7.4, axios@^1.7.7, axios@~1.6.8: version "1.7.4" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.4.tgz#4c8ded1b43683c8dd362973c393f3ede24052aa2" integrity sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==