Skip to content

Commit

Permalink
feat: add staked ETH to metamask mobile homepage and account list menu (
Browse files Browse the repository at this point in the history
#12146)

<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

1. What is the reason for the change?

We want to be able to view the Staked Ethereum balance on the homepage
and have the balance totals update to reflect this

2. What is the improvement/solution?

This PR adds the ability for the metamask-mobile app to view the Staked
Ethereum asset balance in the mobile homepage in the Token List while
linking to the ETH Asset Detail view. It also adds Staked Ethereum to
the homepage total balance and the Account List menu total balances as
well. The functionality is gated behind the
`MM_POOLED_STAKING_UI_ENABLED` flag.

## **Related issues**

Closes: https://consensyssoftware.atlassian.net/browse/STAKE-817

## **Manual testing steps**

1. First launch the mobile application with
`MM_POOLED_STAKING_UI_ENABLED="true"` in `js.env`
2. The homepage should show the `Staked Ethereum` asset balance for
selected account in the homepage even if the balance is 0
3. On mainnet, the percentage +/- of the asset should be listed and
should be the same as ETH
4. On holesky, the percentage +/- of the asset should not be listed
5. On an unsupported network, the Staked Ethereum asset should not show
at all
6. If you click the Staked Ethereum asset it should go to the ETH asset
detail page as Staked ETH is not a real token
7. On the ETH asset detail page, the Staked Ethereum balance should be
the same as the homepage, **there should be no discrepancy**
8. Staking flow should not be affected
9. Total balance on the homepage should include Staked Ethereum balance
10. Total balances per account on the Account List menu dropdown should
also include Staked Ethereum balance
11. All balanced mentioned should update when there is a balance change,
one can go through staking flow to test this
12. **There should be no discrepancies across amounts as they have been
addressed**

## **Screenshots/Recordings**

### **Before**
WIP
### **After**
<img width="391" alt="Screenshot 2024-11-01 at 4 37 21 PM"
src="https://github.com/user-attachments/assets/da9e351a-bdaf-48f6-927c-be7c42f416b7">
<img width="426" alt="Screenshot 2024-11-01 at 4 37 27 PM"
src="https://github.com/user-attachments/assets/4fecbab6-036a-448b-99c6-6565516a3a39">
<img width="388" alt="Screenshot 2024-11-01 at 4 37 44 PM"
src="https://github.com/user-attachments/assets/69076e53-a546-434d-b7cb-6f7fcb00beea">

## **Pre-merge author checklist**

- [x] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I’ve included tests if applicable
- [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
nickewansmith authored Nov 12, 2024
1 parent 9ccffcf commit 3963644
Show file tree
Hide file tree
Showing 11 changed files with 916 additions and 78 deletions.
70 changes: 34 additions & 36 deletions app/components/UI/Stake/hooks/useBalance.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ import { backgroundState } from '../../../../util/test/initial-root-state';
import { renderHookWithProvider } from '../../../../util/test/renderWithProvider';
import useBalance from './useBalance';
import { toHex } from '@metamask/controller-utils';
import usePooledStakes from './usePooledStakes';
import { PooledStake } from '@metamask/stake-sdk';

const MOCK_ADDRESS_1 = '0x0';

Expand All @@ -24,7 +22,7 @@ const initialState = {
AccountTrackerController: {
accountsByChainId: {
'0x1': {
[MOCK_ADDRESS_1]: { balance: toHex('12345678909876543210000000') },
[MOCK_ADDRESS_1]: { balance: toHex('12345678909876543210000000'), stakedBalance: toHex(MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0].assets) },
},
},
},
Expand All @@ -40,25 +38,6 @@ const initialState = {
},
};

jest.mock('../hooks/usePooledStakes');
const mockUsePooledStakes = (
pooledStake: PooledStake,
exchangeRate: string,
) => {
(usePooledStakes as jest.MockedFn<typeof usePooledStakes>).mockReturnValue({
pooledStakesData: pooledStake,
exchangeRate,
isLoadingPooledStakesData: false,
error: null,
refreshPooledStakes: jest.fn(),
hasStakedPositions: true,
hasEthToUnstake: true,
hasNeverStaked: false,
hasRewards: true,
hasRewardsOnly: false,
});
};

describe('useBalance', () => {
afterEach(() => {
jest.clearAllMocks();
Expand All @@ -69,10 +48,6 @@ describe('useBalance', () => {
});

it('returns balance and fiat values based on account and pooled stake data', async () => {
mockUsePooledStakes(
MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0],
MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate,
);
const { result } = renderHookWithProvider(() => useBalance(), {
state: initialState,
});
Expand All @@ -90,10 +65,6 @@ describe('useBalance', () => {
});

it('returns default values when no selected address and no account data', async () => {
mockUsePooledStakes(
MOCK_GET_POOLED_STAKES_API_RESPONSE.accounts[0],
MOCK_GET_POOLED_STAKES_API_RESPONSE.exchangeRate,
);
const { result } = renderHookWithProvider(() => useBalance(), {
state: {
...initialState,
Expand Down Expand Up @@ -121,12 +92,39 @@ describe('useBalance', () => {
});

it('returns correct stake amounts and fiat values based on account with high amount of assets', async () => {
mockUsePooledStakes(
MOCK_GET_POOLED_STAKES_API_RESPONSE_HIGH_ASSETS_AMOUNT.accounts[0],
MOCK_GET_POOLED_STAKES_API_RESPONSE_HIGH_ASSETS_AMOUNT.exchangeRate,
);
const { result } = renderHookWithProvider(() => useBalance(), {
state: initialState,
state: {
...initialState,
engine: {
backgroundState: {
...backgroundState,
AccountsController: createMockAccountsControllerState([
MOCK_ADDRESS_1,
]),
AccountTrackerController: {
accountsByChainId: {
'0x1': {
[MOCK_ADDRESS_1]: {
balance: toHex('12345678909876543210000000'),
stakedBalance: toHex(
MOCK_GET_POOLED_STAKES_API_RESPONSE_HIGH_ASSETS_AMOUNT
.accounts[0].assets,
),
},
},
},
},
CurrencyRateController: {
currentCurrency: 'usd',
currencyRates: {
ETH: {
conversionRate: 3200,
},
},
},
},
},
},
});

expect(result.current.balanceETH).toBe('12345678.90988'); // ETH balance
Expand All @@ -139,6 +137,6 @@ describe('useBalance', () => {
expect(result.current.stakedBalanceWei).toBe('99999999990000000000000'); // No staked assets
expect(result.current.formattedStakedBalanceETH).toBe('99999.99999 ETH'); // Formatted ETH balance
expect(result.current.stakedBalanceFiatNumber).toBe(319999999.968); // Staked balance in fiat number
expect(result.current.formattedStakedBalanceFiat).toBe('$319999999.97'); //
expect(result.current.formattedStakedBalanceFiat).toBe('$319999999.96'); // should round to floor
});
});
28 changes: 17 additions & 11 deletions app/components/UI/Stake/hooks/useBalance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,10 @@ import {
import { selectChainId } from '../../../../selectors/networkController';
import {
hexToBN,
renderFiat,
renderFromWei,
weiToFiat,
weiToFiatNumber,
} from '../../../../util/number';
import usePooledStakes from './usePooledStakes';

const useBalance = () => {
const accountsByChainId = useSelector(selectAccountsByChainId);
Expand All @@ -29,6 +27,10 @@ const useBalance = () => {
? accountsByChainId[chainId]?.[selectedAddress]?.balance
: '0';

const stakedBalance = selectedAddress
? accountsByChainId[chainId]?.[selectedAddress]?.stakedBalance || '0'
: '0';

const balanceETH = useMemo(
() => renderFromWei(rawAccountBalance),
[rawAccountBalance],
Expand All @@ -49,29 +51,33 @@ const useBalance = () => {
[balanceWei, conversionRate],
);

const { pooledStakesData } = usePooledStakes();
const assets = hexToBN(pooledStakesData.assets).toString('hex');
const formattedStakedBalanceETH = useMemo(
() => `${renderFromWei(assets)} ETH`,
[assets],
() => `${renderFromWei(stakedBalance)} ETH`,
[stakedBalance],
);

const stakedBalanceFiatNumber = useMemo(
() => weiToFiatNumber(assets, conversionRate),
[assets, conversionRate],
() => weiToFiatNumber(stakedBalance, conversionRate),
[stakedBalance, conversionRate],
);

const formattedStakedBalanceFiat = useMemo(
() => renderFiat(stakedBalanceFiatNumber, currentCurrency, 2),
[currentCurrency, stakedBalanceFiatNumber],
() => weiToFiat(
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToBN(stakedBalance) as any,
conversionRate,
currentCurrency,
),
[currentCurrency, stakedBalance, conversionRate],
);

return {
balanceETH,
balanceFiat,
balanceWei,
balanceFiatNumber,
stakedBalanceWei: assets ?? '0',
stakedBalanceWei: hexToBN(stakedBalance).toString(),
formattedStakedBalanceETH,
stakedBalanceFiatNumber,
formattedStakedBalanceFiat,
Expand Down
13 changes: 8 additions & 5 deletions app/components/UI/Tokens/TokenList/TokenListItem/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -171,14 +171,19 @@ export const TokenListItem = ({
};

const onItemPress = (token: TokenI) => {
// if the asset is staked, navigate to the native asset details
if (asset.isStaked) {
return navigation.navigate('Asset', {...token.nativeAsset});
}
navigation.navigate('Asset', {
...token,
});
};

return (
<AssetElement
key={itemAddress || '0x'}
// assign staked asset a unique key
key={asset.isStaked ? '0x_staked' : (itemAddress || '0x')}
onPress={onItemPress}
onLongPress={asset.isETH ? null : showRemoveMenu}
asset={asset}
Expand Down Expand Up @@ -216,10 +221,8 @@ export const TokenListItem = ({
<Text variant={TextVariant.BodyLGMedium}>
{asset.name || asset.symbol}
</Text>
{/** Add button link to Portfolio Stake if token is mainnet ETH */}
{asset.isETH && isStakingSupportedChain && (
<StakeButton asset={asset} />
)}
{/** Add button link to Portfolio Stake if token is supported ETH chain and not a staked asset */}
{asset.isETH && isStakingSupportedChain && !asset.isStaked && <StakeButton asset={asset} />}
</View>
{!isTestNet(chainId) ? (
<PercentageChange value={pricePercentChange1d} />
Expand Down
2 changes: 2 additions & 0 deletions app/components/UI/Tokens/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ export interface TokenI {
balanceFiat: string;
logo: string | undefined;
isETH: boolean | undefined;
isStaked?: boolean | undefined;
nativeAsset?: TokenI | undefined;
}
60 changes: 42 additions & 18 deletions app/components/Views/Wallet/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,34 +530,58 @@ const Wallet = ({
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let balance: any = 0;
let assets = tokens;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let stakedBalance: any = 0;

const assets = [
...(tokens || []),
];

if (accountBalanceByChainId) {
balance = renderFromWei(accountBalanceByChainId.balance);

assets = [
{
// TODO: Add name property to Token interface in controllers.
name: getTicker(ticker) === 'ETH' ? 'Ethereum' : ticker,
symbol: getTicker(ticker),
isETH: true,
balance,
balance = renderFromWei(accountBalanceByChainId.balance);
const nativeAsset = {
// TODO: Add name property to Token interface in controllers.
name: getTicker(ticker) === 'ETH' ? 'Ethereum' : ticker,
symbol: getTicker(ticker),
isETH: true,
balance,
balanceFiat: weiToFiat(
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToBN(accountBalanceByChainId.balance) as any,
conversionRate,
currentCurrency,
),
logo: '../images/eth-logo-new.png',
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
assets.push(nativeAsset);

let stakedAsset;
if (accountBalanceByChainId.stakedBalance) {
stakedBalance = renderFromWei(accountBalanceByChainId.stakedBalance);
stakedAsset = {
...nativeAsset,
nativeAsset,
name: 'Staked Ethereum',
isStaked: true,
balance: stakedBalance,
balanceFiat: weiToFiat(
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToBN(accountBalanceByChainId.balance) as any,
hexToBN(accountBalanceByChainId.stakedBalance) as any,
conversionRate,
currentCurrency,
),
logo: '../images/eth-logo-new.png',
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
...(tokens || []),
];
} else {
assets = tokens;
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
assets.push(stakedAsset);
}
}

return (
<View
style={styles.wrapper}
Expand Down
2 changes: 1 addition & 1 deletion app/components/hooks/useAccounts/useAccounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ const MOCK_STORE_STATE = {
AccountTrackerController: {
accounts: {
[MOCK_ACCOUNT_1.address]: {
balance: '0x0',
balance: '0x0', stakedBalance: '0x0',
},
[MOCK_ACCOUNT_2.address]: {
balance: '0x5',
Expand Down
8 changes: 6 additions & 2 deletions app/components/hooks/useAccounts/useAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
} from './useAccounts.types';
import { InternalAccount } from '@metamask/keyring-api';
import { Hex } from '@metamask/utils';
import { BigNumber } from 'ethers';

/**
* Hook that returns both wallet accounts and ens name information.
Expand Down Expand Up @@ -142,12 +143,15 @@ const useAccounts = ({
// TODO - Improve UI to either include loading and/or balance load failures.
const balanceWeiHex =
accountInfoByAddress?.[checksummedAddress]?.balance || '0x0';
const balanceETH = renderFromWei(balanceWeiHex); // Gives ETH
const stakedBalanceWeiHex =
accountInfoByAddress?.[checksummedAddress]?.stakedBalance || '0x0';
const totalBalanceWeiHex = BigNumber.from(balanceWeiHex).add(BigNumber.from(stakedBalanceWeiHex)).toHexString();
const balanceETH = renderFromWei(totalBalanceWeiHex); // Gives ETH
const balanceFiat =
weiToFiat(
// TODO: Replace "any" with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hexToBN(balanceWeiHex) as any,
hexToBN(totalBalanceWeiHex) as any,
conversionRate,
currentCurrency,
) || '';
Expand Down
Loading

0 comments on commit 3963644

Please sign in to comment.