Skip to content

Commit

Permalink
feat: multichain detect tokens feat (#12417)
Browse files Browse the repository at this point in the history
<!--
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**
PR to add the multichain autodetection to mobile under a feature flag
<!--
Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change?
2. What is the improvement/solution?
-->

## **Related issues**

Fixes:

## **Manual testing steps**

1. run `PORTFOLIO_VIEW=true yarn watch`
2. run `yarn start:ios`
3. check if the tokens autodetection is multichained

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->


https://github.com/user-attachments/assets/c4428c82-5fb7-4701-8625-cf7bb36ee4be



## **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.

---------

Co-authored-by: sahar-fehri <[email protected]>
  • Loading branch information
salimtb and sahar-fehri authored Dec 3, 2024
1 parent 57da0a9 commit b2f9eec
Show file tree
Hide file tree
Showing 42 changed files with 4,493 additions and 202 deletions.
108 changes: 92 additions & 16 deletions app/components/UI/AssetOverview/Balance/Balance.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react';
import React, { useCallback } from 'react';
import { View } from 'react-native';
import { Hex } from '@metamask/utils';
import { strings } from '../../../../../locales/i18n';
import { useStyles } from '../../../../component-library/hooks';
import styleSheet from './Balance.styles';
Expand All @@ -9,9 +10,11 @@ import { selectNetworkName } from '../../../../selectors/networkInfos';
import { selectChainId } from '../../../../selectors/networkController';
import {
getTestNetImageByChainId,
getDefaultNetworkByChainId,
isLineaMainnetByChainId,
isMainnetByChainId,
isTestNet,
isPortfolioViewEnabledFunction,
} from '../../../../util/networks';
import images from '../../../../images/image-icons';
import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper';
Expand All @@ -20,31 +23,70 @@ import Badge from '../../../../component-library/components/Badges/Badge/Badge';
import NetworkMainAssetLogo from '../../NetworkMainAssetLogo';
import AvatarToken from '../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
import { AvatarSize } from '../../../../component-library/components/Avatars/Avatar';
import NetworkAssetLogo from '../../NetworkAssetLogo';
import Text, {
TextVariant,
} from '../../../../component-library/components/Texts/Text';
import { TokenI } from '../../Tokens/types';
import { useNavigation } from '@react-navigation/native';
import { isPooledStakingFeatureEnabled } from '../../Stake/constants';
import StakingBalance from '../../Stake/components/StakingBalance/StakingBalance';
import {
PopularList,
UnpopularNetworkList,
CustomNetworkImgMapping,
} from '../../../../util/networks/customNetworks';

interface BalanceProps {
asset: TokenI;
mainBalance: string;
secondaryBalance?: string;
}

export const NetworkBadgeSource = (chainId: string, ticker: string) => {
export const NetworkBadgeSource = (chainId: Hex, ticker: string) => {
const isMainnet = isMainnetByChainId(chainId);
const isLineaMainnet = isLineaMainnetByChainId(chainId);
if (!isPortfolioViewEnabledFunction()) {
if (isTestNet(chainId)) return getTestNetImageByChainId(chainId);
if (isMainnet) return images.ETHEREUM;

if (isLineaMainnet) return images['LINEA-MAINNET'];

if (CustomNetworkImgMapping[chainId]) {
return CustomNetworkImgMapping[chainId];
}

return ticker ? images[ticker as keyof typeof images] : undefined;
}

if (isTestNet(chainId)) return getTestNetImageByChainId(chainId);
const defaultNetwork = getDefaultNetworkByChainId(chainId) as
| {
imageSource: string;
}
| undefined;

if (isMainnet) return images.ETHEREUM;
if (defaultNetwork) {
return defaultNetwork.imageSource;
}

if (isLineaMainnet) return images['LINEA-MAINNET'];
const unpopularNetwork = UnpopularNetworkList.find(
(networkConfig) => networkConfig.chainId === chainId,
);

const customNetworkImg = CustomNetworkImgMapping[chainId];

return ticker ? images[ticker as keyof typeof images] : undefined;
const popularNetwork = PopularList.find(
(networkConfig) => networkConfig.chainId === chainId,
);

const network = unpopularNetwork || popularNetwork;
if (network) {
return network.rpcPrefs.imageSource;
}
if (customNetworkImg) {
return customNetworkImg;
}
};

const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
Expand All @@ -53,6 +95,42 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
const networkName = useSelector(selectNetworkName);
const chainId = useSelector(selectChainId);

const ticker = asset.symbol;

const renderNetworkAvatar = useCallback(() => {
if (!isPortfolioViewEnabledFunction() && asset.isETH) {
return <NetworkMainAssetLogo style={styles.ethLogo} />;
}

if (isPortfolioViewEnabledFunction() && asset.isNative) {
return (
<NetworkAssetLogo
chainId={asset.chainId as Hex}
style={styles.ethLogo}
ticker={asset.symbol}
big={false}
biggest={false}
testID={'PLACE HOLDER'}
/>
);
}

return (
<AvatarToken
name={asset.symbol}
imageSource={{ uri: asset.image }}
size={AvatarSize.Md}
/>
);
}, [
asset.isETH,
asset.image,
asset.symbol,
asset.isNative,
asset.chainId,
styles.ethLogo,
]);

return (
<View style={styles.wrapper}>
<Text variant={TextVariant.HeadingMD} style={styles.title}>
Expand All @@ -62,27 +140,25 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => {
asset={asset}
mainBalance={mainBalance}
balance={secondaryBalance}
onPress={() => !asset.isETH && navigation.navigate('AssetDetails')}
onPress={() =>
!asset.isETH &&
navigation.navigate('AssetDetails', {
chainId: asset.chainId,
address: asset.address,
})
}
>
<BadgeWrapper
style={styles.badgeWrapper}
badgeElement={
<Badge
variant={BadgeVariant.Network}
imageSource={NetworkBadgeSource(chainId, asset.symbol)}
imageSource={NetworkBadgeSource(chainId, ticker)}
name={networkName}
/>
}
>
{asset.isETH ? (
<NetworkMainAssetLogo style={styles.ethLogo} />
) : (
<AvatarToken
name={asset.symbol}
imageSource={{ uri: asset.image }}
size={AvatarSize.Md}
/>
)}
{renderNetworkAvatar()}
</BadgeWrapper>
<Text style={styles.balances} variant={TextVariant.BodyLGMedium}>
{asset.name || asset.symbol}
Expand Down
42 changes: 42 additions & 0 deletions app/components/UI/AssetOverview/Balance/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import { selectChainId } from '../../../../selectors/networkController';
import { Provider, useSelector } from 'react-redux';
import configureMockStore from 'redux-mock-store';
import { backgroundState } from '../../../../util/test/initial-root-state';
import { NetworkBadgeSource } from './Balance';
import { isPortfolioViewEnabledFunction } from '../../../../util/networks';

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
Expand Down Expand Up @@ -58,6 +60,16 @@ const mockInitialState = {
},
};

jest.mock('../../../../util/networks', () => ({
...jest.requireActual('../../../../util/networks'),
getTestNetImageByChainId: jest.fn((chainId) => `testnet-image-${chainId}`),
}));

jest.mock('../../../../util/networks', () => ({
...jest.requireActual('../../../../util/networks'),
isPortfolioViewEnabledFunction: jest.fn(),
}));

describe('Balance', () => {
const mockStore = configureMockStore();
const store = mockStore(mockInitialState);
Expand Down Expand Up @@ -120,4 +132,34 @@ describe('Balance', () => {
fireEvent.press(assetElement);
expect(mockNavigate).toHaveBeenCalledTimes(0);
});

describe('NetworkBadgeSource', () => {
it('returns testnet image for a testnet chainId', () => {
const result = NetworkBadgeSource('0xaa36a7', 'ETH');
expect(result).toBeDefined();
});

it('returns mainnet Ethereum image for mainnet chainId', () => {
const result = NetworkBadgeSource('0x1', 'ETH');
expect(result).toBeDefined();
});

it('returns Linea Mainnet image for Linea mainnet chainId', () => {
const result = NetworkBadgeSource('0xe708', 'LINEA');
expect(result).toBeDefined();
});

it('returns undefined if no image is found', () => {
const result = NetworkBadgeSource('0x999', 'UNKNOWN');
expect(result).toBeUndefined();
});

it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => {
(isPortfolioViewEnabledFunction as jest.Mock).mockImplementation(
() => true,
);
const result = NetworkBadgeSource('0xe708', 'LINEA');
expect(result).toBeDefined();
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`NetworkAssetLogo Component matches the snapshot for non-mainnet 1`] = `null`;
73 changes: 73 additions & 0 deletions app/components/UI/NetworkAssetLogo/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React from 'react';
import { render } from '@testing-library/react-native';
import NetworkAssetLogo from '.';
import TokenIcon from '../Swaps/components/TokenIcon';
import { ChainId } from '@metamask/controller-utils';

// Mock the TokenIcon component
jest.mock('../Swaps/components/TokenIcon', () => jest.fn(() => null));

describe('NetworkAssetLogo Component', () => {
it('matches the snapshot for non-mainnet', () => {
const { toJSON } = render(
<NetworkAssetLogo
chainId="42"
ticker="DAI"
style={{}}
big
biggest={false}
testID="network-asset-logo"
/>,
);

expect(toJSON()).toMatchSnapshot();
});

it('renders TokenIcon with ETH for mainnet chainId', () => {
const props = {
chainId: ChainId.mainnet,
ticker: 'TEST',
style: { width: 50, height: 50 },
big: true,
biggest: false,
testID: 'network-asset-logo',
};

render(<NetworkAssetLogo {...props} />);

expect(TokenIcon).toHaveBeenCalledWith(
{
big: props.big,
biggest: props.biggest,
symbol: 'ETH',
style: props.style,
testID: props.testID,
},
{},
);
});

it('renders TokenIcon with ticker for non-mainnet chainId', () => {
const props = {
chainId: '0x38', // Binance Smart Chain
ticker: 'BNB',
style: { width: 40, height: 40 },
big: false,
biggest: true,
testID: 'network-asset-logo',
};

render(<NetworkAssetLogo {...props} />);

expect(TokenIcon).toHaveBeenCalledWith(
{
big: props.big,
biggest: props.biggest,
symbol: props.ticker,
style: props.style,
testID: props.testID,
},
{},
);
});
});
44 changes: 44 additions & 0 deletions app/components/UI/NetworkAssetLogo/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import React from 'react';
import { ChainId } from '@metamask/controller-utils';
import TokenIcon from '../Swaps/components/TokenIcon';

interface NetworkAssetLogoProps {
chainId: string;
ticker: string;
style: object;
big: boolean;
biggest: boolean;
testID: string;
}

function NetworkAssetLogo({
chainId,
ticker,
style,
big,
biggest,
testID,
}: NetworkAssetLogoProps) {
if (chainId === ChainId.mainnet) {
return (
<TokenIcon
big={big}
biggest={biggest}
symbol={'ETH'}
style={style}
testID={testID}
/>
);
}
return (
<TokenIcon
big={big}
biggest={biggest}
symbol={ticker}
style={style}
testID={testID}
/>
);
}

export default NetworkAssetLogo;
Loading

0 comments on commit b2f9eec

Please sign in to comment.