Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

EVM NFT section #15467

Draft
wants to merge 10 commits into
base: develop
Choose a base branch
from
5 changes: 5 additions & 0 deletions packages/blockchain-link-types/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
AddressAlias,
TokenTransfer as BlockbookTokenTransfer,
ContractInfo,
MultiTokenValue,
StakingPool,
} from './blockbook-api';

Expand Down Expand Up @@ -188,6 +189,10 @@ export interface TokenInfo {
accounts?: TokenAccount[]; // token accounts for solana
policyId?: string; // Cardano policy id
fingerprint?: string; // Cardano starting with "asset"
multiTokenValues?: MultiTokenValue[];
ids?: string[];
totalReceived?: string;
totalSent?: string;
// transfers: number, // total transactions?
}

Expand Down
2 changes: 2 additions & 0 deletions packages/suite-desktop-ui/src/support/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { SettingsCoins } from 'src/views/settings/SettingsCoins/SettingsCoins';
import { SettingsDebug } from 'src/views/settings/SettingsDebug/SettingsDebug';
import { SettingsDevice } from 'src/views/settings/SettingsDevice/SettingsDevice';
import { Tokens } from 'src/views/wallet/tokens';
import { Nfts } from 'src/views/wallet/nfts';
import PasswordManager from 'src/views/password-manager';

const components: { [key: string]: ComponentType<any> } = {
Expand All @@ -47,6 +48,7 @@ const components: { [key: string]: ComponentType<any> } = {
'wallet-sign-verify': WalletSignVerify,
'wallet-anonymize': WalletAnonymize,
'wallet-tokens': Tokens,
'wallet-nfts': Nfts,
'wallet-coinmarket-buy': CoinmarketBuyForm,
'wallet-coinmarket-buy-detail': CoinmarketBuyDetail,
'wallet-coinmarket-buy-offers': CoinmarketBuyOffers,
Expand Down
1 change: 1 addition & 0 deletions packages/suite-web/src/support/Router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ const components: Record<PageName, LazyExoticComponent<ComponentType<any>>> = {
() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/details'),
),
'wallet-tokens': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/tokens')),
'wallet-nfts': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/nfts')),
'wallet-send': lazy(() => import(/* webpackChunkName: "wallet" */ 'src/views/wallet/send')),
'wallet-staking': lazy(() =>
import(/* webpackChunkName: "wallet" */ 'src/views/wallet/staking/WalletStaking').then(
Expand Down
12 changes: 12 additions & 0 deletions packages/suite/src/actions/suite/copyAddressActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,15 @@ export const copyAddressToClipboard = (address: string) => (dispatch: Dispatch)
dispatch(notificationsActions.addToast({ type: 'copy-to-clipboard' }));
}
};

export const onCopyAddressWithModal = (
address: string,
addressType: AddressType,
shouldShowCopyAddressModal: boolean,
) => {
if (shouldShowCopyAddressModal) {
showCopyAddressModal(address, addressType);
} else {
copyAddressToClipboard(address);
}
};
4 changes: 2 additions & 2 deletions packages/suite/src/components/suite/FormattedNftAmount.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export const FormattedNftAmount = ({
) : (
<Row gap={spacings.xxs}>
<Row>{token.value}x</Row>
<Translation id="TR_TOKEN_ID" />
<Translation id="TR_TOKEN_ID_COLON" />
</Row>
)}
</Row>
Expand Down Expand Up @@ -95,7 +95,7 @@ export const FormattedNftAmount = ({
<Row className={className}>
{signValue ? <Sign value={signValue} /> : null}
<Box margin={{ right: spacings.xxs }}>
<Translation id="TR_TOKEN_ID" />
<Translation id="TR_TOKEN_ID_COLON" />
</Box>
{isWithLink ? (
<TrezorLink
Expand Down
7 changes: 5 additions & 2 deletions packages/suite/src/components/wallet/TokenIconSetWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ export const TokenIconSetWrapper = ({ accounts, symbol }: TokenIconSetWrapperPro

if (!allTokensWithRates.length) return null;

const tokens = getTokens(allTokensWithRates, symbol, coinDefinitions)
.shownWithBalance as TokensWithRates[];
const tokens = getTokens({
tokens: allTokensWithRates,
symbol,
tokenDefinitions: coinDefinitions,
})?.shownWithBalance as TokensWithRates[];

const aggregatedTokens = Object.values(
tokens.reduce((acc: Record<string, TokensWithRates>, token) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { useDispatch, useSelector } from 'src/hooks/suite';
import { goto } from 'src/actions/suite/routerActions';
import { selectSelectedAccount } from 'src/reducers/wallet/selectedAccountReducer';
import { NavigationItem, SubpageNavigation } from 'src/components/suite/layouts/SuiteLayout';

import { selectHasExperimentalFeature } from 'src/reducers/suite/suiteReducer';
export const ACCOUNT_TABS = [
'wallet-index',
'wallet-details',
'wallet-tokens',
'wallet-nfts',
'wallet-nfts-hidden',
'wallet-tokens-hidden',
'wallet-staking',
];
Expand All @@ -21,7 +23,7 @@ export const AccountNavigation = () => {
const account = useSelector(selectSelectedAccount);
const routerParams = useSelector(state => state.router.params) as WalletParams;
const dispatch = useDispatch();

const enabledNftSection = useSelector(selectHasExperimentalFeature('nft-section'));
const network = getNetworkOptional(routerParams?.symbol);
const networkType = account?.networkType || network?.networkType || '';

Expand Down Expand Up @@ -54,6 +56,16 @@ export const AccountNavigation = () => {
activeRoutes: ['wallet-tokens', 'wallet-tokens-hidden'],
'data-testid': '@wallet/menu/wallet-tokens',
},
{
id: 'wallet-nfts',
callback: () => {
goToWithAnalytics('wallet-nfts', { preserveParams: true });
},
title: <Translation id="TR_NAV_NFTS" />,
isHidden: !hasNetworkFeatures(account, 'nfts') || !enabledNftSection,
activeRoutes: ['wallet-nfts', 'wallet-nfts-hidden'],
'data-testid': '@wallet/menu/wallet-nfts',
},
{
id: 'wallet-staking',
callback: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ export const AccountSection = ({

const showGroup = ['ethereum', 'solana', 'cardano'].includes(networkType);

const tokens = getTokens(accountTokens, account.symbol, coinDefinitions);
const tokens = getTokens({
tokens: accountTokens,
symbol: account.symbol,
tokenDefinitions: coinDefinitions,
});

const dataTestKey = `@account-menu/${symbol}/${accountType}/${index}`;

Expand Down
6 changes: 5 additions & 1 deletion packages/suite/src/constants/suite/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { Route } from '@suite-common/suite-types';

import { Dispatch } from '../../types/suite';

export type ExperimentalFeature = 'password-manager' | 'tor-snowflake';
export type ExperimentalFeature = 'password-manager' | 'tor-snowflake' | 'nft-section';

export type ExperimentalFeatureConfig = {
title: TranslationKey;
Expand Down Expand Up @@ -39,4 +39,8 @@ export const EXPERIMENTAL_FEATURES: Record<ExperimentalFeature, ExperimentalFeat
}
},
},
'nft-section': {
title: 'TR_EXPERIMENTAL_NFT_SECTION',
description: 'TR_EXPERIMENTAL_NFT_SECTION_DESCRIPTION',
},
};
64 changes: 63 additions & 1 deletion packages/suite/src/support/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2595,6 +2595,14 @@ export default defineMessages({
defaultMessage: 'Unrecognized tokens pose potential risks. Use caution.',
id: 'TR_TOKEN_UNRECOGNIZED_BY_TREZOR_TOOLTIP',
},
TR_COLLECTIONS_UNRECOGNIZED_BY_TREZOR: {
defaultMessage: 'Unrecognized collections',
id: 'TR_COLLECTIONS_UNRECOGNIZED_BY_TREZOR',
},
TR_NFT_UNRECOGNIZED_BY_TREZOR_TOOLTIP: {
defaultMessage: 'Unrecognized NFTs pose potential risks. Use caution.',
id: 'TR_NFT_UNRECOGNIZED_BY_TREZOR_TOOLTIP',
},
TR_LEARN: {
defaultMessage: 'Learn',
description: 'Link to Suite Guide.',
Expand Down Expand Up @@ -2718,6 +2726,14 @@ export default defineMessages({
defaultMessage: 'Tokens',
id: 'TR_NAV_TOKENS',
},
TR_NAV_COLLECTIONS: {
defaultMessage: 'Collections',
id: 'TR_NAV_COLLECTIONS',
},
TR_NAV_NFTS: {
defaultMessage: 'NFTs',
id: 'TR_NAV_NFTS',
},
TR_NAV_SIGN_AND_VERIFY: {
defaultMessage: 'Sign & verify',
description:
Expand Down Expand Up @@ -3307,8 +3323,13 @@ export default defineMessages({
defaultMessage: 'Details',
id: 'TR_TRANSACTION_DETAILS',
},
TR_TOKEN_ID: {
TR_TOKEN_ID_COLON: {
defaultMessage: 'Token ID:',
id: 'TR_TOKEN_ID_COLON',
},

TR_TOKEN_ID: {
defaultMessage: 'Token ID',
id: 'TR_TOKEN_ID',
},
TR_NO_TRANSPORT: {
Expand Down Expand Up @@ -4979,6 +5000,15 @@ export default defineMessages({
defaultMessage: 'Experimental',
description: 'Section title for Early Access program so far',
},
TR_EXPERIMENTAL_NFT_SECTION: {
id: 'TR_EXPERIMENTAL_NFT_SECTION',
defaultMessage: 'NFTs (non-fungible tokens)',
},
TR_EXPERIMENTAL_NFT_SECTION_DESCRIPTION: {
id: 'TR_EXPERIMENTAL_NFT_SECTION_DESCRIPTION',
defaultMessage:
'Access the NFTs stored in your wallet. Currently available for EVM-based chains only.',
},
TR_EXPERIMENTAL_FEATURES_ALLOW: {
id: 'TR_EXPERIMENTAL_FEATURES_ALLOW',
defaultMessage: 'Experimental features',
Expand Down Expand Up @@ -5294,18 +5324,42 @@ export default defineMessages({
id: 'TR_TOKENS',
defaultMessage: 'Tokens',
},
TR_COLLECTION_NAME: {
id: 'TR_COLLECTION_NAME',
defaultMessage: 'Collection name',
},
TR_ID: {
id: 'TR_ID',
defaultMessage: 'ID',
},
TR_NFT: {
id: 'TR_NFT',
defaultMessage: 'NFT',
},
TR_TOKENS_EMPTY: {
id: 'TR_TOKENS_EMPTY',
defaultMessage: 'No tokens... yet.',
},
TR_NFT_EMPTY: {
id: 'TR_NFT_EMPTY',
defaultMessage: 'No NFT collections... yet.',
},
TR_TOKENS_EMPTY_CHECK_HIDDEN: {
id: 'TR_TOKENS_EMPTY_CHECK_HIDDEN',
defaultMessage: 'No tokens. They may be hidden.',
},
TR_NFT_EMPTY_CHECK_HIDDEN: {
id: 'TR_NFT_EMPTY_CHECK_HIDDEN',
defaultMessage: 'No NFT collections. They may be hidden.',
},
TR_HIDDEN_TOKENS_EMPTY: {
id: 'TR_HIDDEN_TOKENS_EMPTY',
defaultMessage: 'You have no hidden tokens.',
},
TR_HIDDEN_NFT_EMPTY: {
id: 'TR_HIDDEN_NFT_EMPTY',
defaultMessage: 'You have no hidden NFT collections.',
},
TR_ADD_TOKEN_TITLE: {
id: 'TR_ADD_TOKEN_TITLE',
defaultMessage: 'Add ERC20 token',
Expand Down Expand Up @@ -5395,6 +5449,10 @@ export default defineMessages({
defaultMessage: 'Amount',
id: 'AMOUNT',
},
TR_QUANTITY: {
defaultMessage: 'Quantity',
id: 'TR_QUANTITY',
},
AMOUNT_SEND_MAX: {
id: 'AMOUNT_SEND_MAX',
defaultMessage: 'Send max',
Expand Down Expand Up @@ -6364,6 +6422,10 @@ export default defineMessages({
id: 'TR_UNHIDE_TOKEN',
defaultMessage: 'Unhide token',
},
TR_HIDE_COLLECTION: {
id: 'TR_HIDE_COLLECTION',
defaultMessage: 'Hide collection',
},
TR_UNHIDE: {
id: 'TR_UNHIDE',
defaultMessage: 'Unhide',
Expand Down
67 changes: 50 additions & 17 deletions packages/suite/src/utils/wallet/tokenUtils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { BigNumber } from '@trezor/utils/src/bigNumber';
import { Account, Rate, TokenAddress, RatesByKey } from '@suite-common/wallet-types';
import { TokenInfo } from '@trezor/connect';
import { getFiatRateKey, isNftToken, isTokenMatchesSearch } from '@suite-common/wallet-utils';
import {
getFiatRateKey,
isNftMatchesSearch,
isNftToken,
isTokenMatchesSearch,
} from '@suite-common/wallet-utils';
import { NetworkSymbol, getNetworkFeatures } from '@suite-common/wallet-config';
import { FiatCurrencyCode } from '@suite-common/suite-config';
import {
Expand Down Expand Up @@ -65,16 +70,37 @@ export const formatTokenSymbol = (symbol: string) => {
return isTokenSymbolLong ? `${upperCasedSymbol.slice(0, 7)}...` : upperCasedSymbol;
};

export const getTokens = (
tokens: EnhancedTokenInfo[] | TokenInfo[],
symbol: NetworkSymbol,
coinDefinitions?: TokenDefinition,
searchQuery?: string,
) => {
// filter out NFT tokens until we implement them
const tokensWithoutNFTs = tokens.filter(token => !isNftToken(token));
type GetTokens = {
tokens: EnhancedTokenInfo[] | TokenInfo[];
symbol: NetworkSymbol;
tokenDefinitions?: TokenDefinition;
searchQuery?: string;
isNft?: boolean;
};

export type GetTokensOutputType = {
shownWithBalance: EnhancedTokenInfo[];
shownWithoutBalance: EnhancedTokenInfo[];
hiddenWithBalance: EnhancedTokenInfo[];
hiddenWithoutBalance: EnhancedTokenInfo[];
unverifiedWithBalance: EnhancedTokenInfo[];
unverifiedWithoutBalance: EnhancedTokenInfo[];
};

const hasCoinDefinitions = getNetworkFeatures(symbol).includes('coin-definitions');
export const getTokens = ({
tokens = [],
symbol,
tokenDefinitions,
searchQuery,
isNft = false,
}: GetTokens): GetTokensOutputType => {
const filteredTokens = isNft
? tokens.filter(token => isNftToken(token))
: tokens.filter(token => !isNftToken(token));

const hasDefinitions = getNetworkFeatures(symbol).includes(
isNft ? 'nft-definitions' : 'coin-definitions',
);

const shownWithBalance: EnhancedTokenInfo[] = [];
const shownWithoutBalance: EnhancedTokenInfo[] = [];
Expand All @@ -83,16 +109,23 @@ export const getTokens = (
const unverifiedWithBalance: EnhancedTokenInfo[] = [];
const unverifiedWithoutBalance: EnhancedTokenInfo[] = [];

tokensWithoutNFTs.forEach(token => {
const isKnown = isTokenDefinitionKnown(coinDefinitions?.data, symbol, token.contract);
const isHidden = coinDefinitions?.hide.includes(token.contract);
const isShown = coinDefinitions?.show.includes(token.contract);
filteredTokens.forEach(token => {
const isKnown = isTokenDefinitionKnown(tokenDefinitions?.data, symbol, token.contract);
const isHidden = tokenDefinitions?.hide.includes(token.contract);
const isShown = tokenDefinitions?.show.includes(token.contract);

const query = searchQuery ? searchQuery.trim().toLowerCase() : '';

if (searchQuery && !isTokenMatchesSearch(token, query)) return;
if (
searchQuery &&
(!isTokenMatchesSearch(token, query) ||
(isNft && isNftMatchesSearch(token as TokenInfo, query)))
)
return;

const hasBalance = new BigNumber(token?.balance || '0').gt(0);
const hasBalance =
new BigNumber(token?.balance || '0').gt(0) ||
(isNft && (token?.multiTokenValues?.length || 0) > 0);

const pushToArray = (
arrayWithBalance: EnhancedTokenInfo[],
Expand All @@ -107,7 +140,7 @@ export const getTokens = (

if (isShown) {
pushToArray(shownWithBalance, shownWithoutBalance);
} else if (hasCoinDefinitions && !isKnown) {
} else if (hasDefinitions && !isKnown) {
pushToArray(unverifiedWithBalance, unverifiedWithoutBalance);
} else if (isHidden) {
pushToArray(hiddenWithBalance, hiddenWithoutBalance);
Expand Down
Loading
Loading