diff --git a/packages/blockchain-link-types/src/common.ts b/packages/blockchain-link-types/src/common.ts index 75004777e2cf..16d93f14cc5d 100644 --- a/packages/blockchain-link-types/src/common.ts +++ b/packages/blockchain-link-types/src/common.ts @@ -5,6 +5,7 @@ import type { AddressAlias, TokenTransfer as BlockbookTokenTransfer, ContractInfo, + MultiTokenValue, StakingPool, } from './blockbook-api'; @@ -55,6 +56,8 @@ export interface ServerInfo { export type TokenStandard = 'ERC20' | 'ERC1155' | 'ERC721' | 'SPL' | 'BEP20'; +export type EvmNftTokenStandard = Extract; + export type TransferType = 'sent' | 'recv' | 'self' | 'unknown'; /* Transaction */ @@ -188,6 +191,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? } diff --git a/packages/suite-desktop-ui/src/support/Router.tsx b/packages/suite-desktop-ui/src/support/Router.tsx index 4f954558a955..dd7e7a928d52 100644 --- a/packages/suite-desktop-ui/src/support/Router.tsx +++ b/packages/suite-desktop-ui/src/support/Router.tsx @@ -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 } = { @@ -47,6 +48,7 @@ const components: { [key: string]: ComponentType } = { '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, diff --git a/packages/suite-web/src/support/Router.tsx b/packages/suite-web/src/support/Router.tsx index 5e09600f4b35..77f67f69128f 100644 --- a/packages/suite-web/src/support/Router.tsx +++ b/packages/suite-web/src/support/Router.tsx @@ -31,6 +31,7 @@ const components: Record>> = { () => 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( diff --git a/packages/suite/src/components/wallet/TokenIconSetWrapper.tsx b/packages/suite/src/components/wallet/TokenIconSetWrapper.tsx index e4f97bb0fbc1..834af3bdf408 100644 --- a/packages/suite/src/components/wallet/TokenIconSetWrapper.tsx +++ b/packages/suite/src/components/wallet/TokenIconSetWrapper.tsx @@ -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: symbol, + tokenDefinitions: coinDefinitions, + })?.shownWithBalance as TokensWithRates[]; const aggregatedTokens = Object.values( tokens.reduce((acc: Record, token) => { diff --git a/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx b/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx index 9a61238f7b9e..8d3e464bf5a9 100644 --- a/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx +++ b/packages/suite/src/components/wallet/WalletLayout/AccountTopPanel/AccountNavigation.tsx @@ -13,6 +13,8 @@ export const ACCOUNT_TABS = [ 'wallet-index', 'wallet-details', 'wallet-tokens', + 'wallet-nfts', + 'wallet-nfts-hidden', 'wallet-tokens-hidden', 'wallet-staking', ]; @@ -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: , + isHidden: !hasNetworkFeatures(account, 'nft'), + activeRoutes: ['wallet-nfts', 'wallet-nfts-hidden'], + 'data-testid': '@wallet/menu/wallet-nfts', + }, { id: 'wallet-staking', callback: () => { diff --git a/packages/suite/src/components/wallet/WalletLayout/AccountsMenu/AccountSection.tsx b/packages/suite/src/components/wallet/WalletLayout/AccountsMenu/AccountSection.tsx index 06dc023a1d5c..add8171ed94c 100644 --- a/packages/suite/src/components/wallet/WalletLayout/AccountsMenu/AccountSection.tsx +++ b/packages/suite/src/components/wallet/WalletLayout/AccountsMenu/AccountSection.tsx @@ -39,18 +39,22 @@ 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}`; - return showGroup && (isStakeShown || tokens.shownWithBalance.length) ? ( + return showGroup && (isStakeShown || tokens?.shownWithBalance?.length) ? ( ) : ( @@ -62,7 +66,7 @@ export const AccountSection = ({ onClick={onItemClick} accountLabel={accountLabel} formattedBalance={formattedBalance} - tokens={tokens.shownWithBalance} + tokens={tokens?.shownWithBalance} dataTestKey={dataTestKey} /> ); diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 0a52c6784ce8..94e3b40d8058 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -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.', @@ -2718,6 +2726,10 @@ export default defineMessages({ defaultMessage: 'Tokens', id: 'TR_NAV_TOKENS', }, + TR_NAV_NFTS: { + defaultMessage: 'NFT', + id: 'TR_NAV_NFTS', + }, TR_NAV_SIGN_AND_VERIFY: { defaultMessage: 'Sign & verify', description: @@ -5289,14 +5301,34 @@ 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.', diff --git a/packages/suite/src/utils/wallet/tokenUtils.ts b/packages/suite/src/utils/wallet/tokenUtils.ts index 137411668bff..fe598c8da800 100644 --- a/packages/suite/src/utils/wallet/tokenUtils.ts +++ b/packages/suite/src/utils/wallet/tokenUtils.ts @@ -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 { @@ -9,6 +14,7 @@ import { TokenDefinition, isTokenDefinitionKnown, } from '@suite-common/token-definitions'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; export interface TokensWithRates extends TokenInfo { fiatValue: BigNumber; @@ -65,16 +71,46 @@ export const formatTokenSymbol = (symbol: string) => { return isTokenSymbolLong ? `${upperCasedSymbol.slice(0, 7)}...` : upperCasedSymbol; }; -export const getTokens = ( - tokens: EnhancedTokenInfo[] | TokenInfo[], - symbol: NetworkSymbol, - coinDefinitions?: TokenDefinition, - searchQuery?: string, -) => { +type GetTokens = { + tokens: EnhancedTokenInfo[] | TokenInfo[]; + symbol: NetworkSymbol; + tokenDefinitions?: TokenDefinition; + searchQuery?: string; + isNft?: boolean; +}; + +export type TokensResult = { + shownWithBalance: EnhancedTokenInfo[]; + shownWithoutBalance: EnhancedTokenInfo[]; + hiddenWithBalance: EnhancedTokenInfo[]; + hiddenWithoutBalance: EnhancedTokenInfo[]; + unverifiedWithBalance: EnhancedTokenInfo[]; + unverifiedWithoutBalance: EnhancedTokenInfo[]; +}; + +// export type NftTokensResult = { +// shownVerified: EnhancedTokenInfo[]; +// shownUnverified: EnhancedTokenInfo[]; +// hiddenVerified: EnhancedTokenInfo[]; +// hiddenUnverified: EnhancedTokenInfo[]; +// }; + +export const getTokens = ({ + tokens = [], + symbol, + tokenDefinitions, + searchQuery, + isNft = false, +}: GetTokens): TokensResult => { // filter out NFT tokens until we implement them - const tokensWithoutNFTs = tokens.filter(token => !isNftToken(token)); - const hasCoinDefinitions = getNetworkFeatures(symbol).includes('coin-definitions'); + 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[] = []; @@ -83,16 +119,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 Token, 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[], @@ -107,7 +150,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); diff --git a/packages/suite/src/views/dashboard/AssetsView/assetsViewUtils.ts b/packages/suite/src/views/dashboard/AssetsView/assetsViewUtils.ts index 3dae8884a231..79f0949bbba5 100644 --- a/packages/suite/src/views/dashboard/AssetsView/assetsViewUtils.ts +++ b/packages/suite/src/views/dashboard/AssetsView/assetsViewUtils.ts @@ -23,9 +23,13 @@ export const handleTokensAndStakingData = ( const assetStakingBalance = accountsThatStaked.reduce((total, account) => { return total.plus(getAccountTotalStakingBalance(account)); }, new BigNumber(0)); - const tokens = getTokens(assetTokens ?? [], symbol, coinDefinitions); + const tokens = getTokens({ + tokens: assetTokens ?? [], + symbol, + tokenDefinitions: coinDefinitions, + }); const tokensWithRates = enhanceTokensWithRates( - tokens.shownWithBalance ?? [], + tokens?.shownWithBalance ?? [], localCurrency, symbol, currentFiatRates, diff --git a/packages/suite/src/views/wallet/nfts/HiddenNfts.tsx b/packages/suite/src/views/wallet/nfts/HiddenNfts.tsx new file mode 100644 index 000000000000..b65ae6b958e5 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/HiddenNfts.tsx @@ -0,0 +1,74 @@ +import { Banner, Column, H3 } from '@trezor/components'; +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { selectNftDefinitions } from '@suite-common/token-definitions'; + +import { Translation } from 'src/components/suite'; +import { useSelector } from 'src/hooks/suite'; +import { getTokens } from 'src/utils/wallet/tokenUtils'; + +import NftsTable from './NftsTable/NftsTable'; +import { NoTokens } from '../tokens/common/NoTokens'; + +type NftsTableProps = { + selectedAccount: SelectedAccountLoaded; + searchQuery: string; +}; + +export const HiddenNfts = ({ selectedAccount, searchQuery }: NftsTableProps) => { + // const filteredTokens = filterNftTokens(selectedAccount.account.tokens || []); + const nftDefinitions = useSelector(state => + selectNftDefinitions(state, selectedAccount.account.symbol), + ); + const nfts = getTokens({ + tokens: selectedAccount.account.tokens ?? [], + symbol: selectedAccount.account.symbol, + tokenDefinitions: nftDefinitions, + isNft: true, + searchQuery, + }); + + return nfts && + (nfts.hiddenWithBalance.length > 0 || + nfts.hiddenWithoutBalance.length > 0 || + nfts.unverifiedWithBalance.length > 0 || + nfts.unverifiedWithoutBalance.length > 0) ? ( + + + +

+ +

+ + + + + +
+ ) : ( + } /> + ); +}; diff --git a/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx b/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx new file mode 100644 index 000000000000..9e462c2fd9fa --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsTable/NftsRow.tsx @@ -0,0 +1,227 @@ +import { Network } from '@suite-common/wallet-config'; +import { + DefinitionType, + tokenDefinitionsActions, + TokenManagementAction, + EnhancedTokenInfo, +} from '@suite-common/token-definitions'; +import { + Button, + Column, + Dropdown, + GroupedMenuItems, + Icon, + Row, + Table, + TruncateWithTooltip, +} from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { copyToClipboard } from '@trezor/dom-utils'; +import { notificationsActions } from '@suite-common/toast-notifications'; +import { AddressType } from '@suite-common/wallet-types'; +import { EvmNftTokenStandard } from '@trezor/blockchain-link-types'; +import { getNftExplorerUrl } from '@suite-common/wallet-utils'; + +import { + HiddenPlaceholder, + RedactNumericalValue, + Translation, + TrezorLink, +} from 'src/components/suite'; +import { useDispatch, useSelector } from 'src/hooks/suite'; +import { SUITE } from 'src/actions/suite/constants'; +import { selectIsCopyAddressModalShown } from 'src/reducers/suite/suiteReducer'; +import { openModal } from 'src/actions/suite/modalActions'; + +import { BlurUrls } from '../../tokens/common/BlurUrls'; + +type NftsRowProps = { + nft: EnhancedTokenInfo; + type: EvmNftTokenStandard; + network: Network; + isShown?: boolean; +}; + +const NftsRow = ({ nft, type, network, isShown }: NftsRowProps) => { + const dispatch = useDispatch(); + + const shouldShowCopyAddressModal = useSelector(selectIsCopyAddressModalShown); + + const getNftContractExplorerUrl = (network: Network, nft: EnhancedTokenInfo) => { + const explorerUrl = network.explorer.account; + const contractAddress = nft.contract; + const queryString = network.explorer.queryString ?? ''; + + return `${explorerUrl}${contractAddress}${queryString}`; + }; + + const onCopyAddress = (address: string, addressType: AddressType) => { + if (shouldShowCopyAddressModal) { + dispatch( + openModal({ + type: 'copy-address', + addressType, + address, + }), + ); + } else { + const result = copyToClipboard(address); + if (typeof result !== 'string') { + dispatch(notificationsActions.addToast({ type: 'copy-to-clipboard' })); + } + } + }; + + return ( + + + + + + + {type === 'ERC1155' && ( + <> + + + {nft.multiTokenValues?.map((value, index) => ( + + + + + + + + ))} + + + + + {nft.multiTokenValues && + nft.multiTokenValues.map((value, index) => ( + + + + + + ))} + + + + )} + {type === 'ERC721' && ( + + + {nft.ids?.map((id, index) => ( + + + + + + ))} + + + )} + + , + icon: 'hide', + onClick: () => + dispatch( + tokenDefinitionsActions.setTokenStatus({ + symbol: network.symbol, + contractAddress: nft.contract || '', + status: TokenManagementAction.HIDE, + type: DefinitionType.NFT, + }), + ), + isHidden: isShown === false, + }, + { + label: , + icon: 'newspaper', + onClick: () => { + dispatch({ + type: SUITE.SET_TRANSACTION_HISTORY_PREFILL, + payload: nft.contract || '', + }); + }, + }, + { + label: , + icon: 'arrowUpRight', + onClick: () => { + window.open( + getNftContractExplorerUrl(network, nft), + '_blank', + ); + }, + }, + ], + }, + { + key: 'contract-address', + label: , + options: [ + { + label: ( + + {nft.contract} + + + ), + onClick: () => + onCopyAddress(nft.contract || '', 'contract'), + }, + ], + }, + ].filter(category => category) as GroupedMenuItems[] + } + /> + {!isShown && ( + + )} + + + ); +}; + +export default NftsRow; diff --git a/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx b/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx new file mode 100644 index 000000000000..f1a0d138ee89 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/NftsTable/NftsTable.tsx @@ -0,0 +1,86 @@ +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { Card, Column, Table, H3 } from '@trezor/components'; +import { getNetwork } from '@suite-common/wallet-config'; +import { EnhancedTokenInfo } from '@suite-common/token-definitions'; +import { EvmNftTokenStandard } from '@trezor/blockchain-link-types'; + +import { TokensResult } from 'src/utils/wallet/tokenUtils'; +import { Translation } from 'src/components/suite/Translation'; + +import NftsRow from './NftsRow'; + +type NftsTableProps = { + selectedAccount: SelectedAccountLoaded; + type: EvmNftTokenStandard; + isShown?: boolean; + verified?: boolean; + nfts: TokensResult; +}; + +const NftsTable = ({ selectedAccount, type, isShown, verified, nfts }: NftsTableProps) => { + const { account } = selectedAccount; + const network = getNetwork(account.symbol); + + const filterNftsByType = (nfts: EnhancedTokenInfo[]) => { + return nfts.filter(nft => nft.type === type); + }; + + const getNftsToShow = () => { + if (isShown) { + return [...nfts.shownWithBalance, ...nfts.shownWithoutBalance]; + } + + return verified + ? [...nfts.hiddenWithBalance, ...nfts.hiddenWithoutBalance] + : [...nfts.unverifiedWithBalance, ...nfts.unverifiedWithoutBalance]; + }; + + const nftsToShow = getNftsToShow(); + const filteredNfts = filterNftsByType(nftsToShow); + + return filteredNfts.length > 0 ? ( + +

{type}

+ + + + + + + + {type === 'ERC1155' && ( + <> + + + + + + + + + )} + {type === 'ERC721' && ( + + + + )} + + + + {filteredNfts.map(nft => ( + + ))} + +
+
+
+ ) : null; +}; + +export default NftsTable; diff --git a/packages/suite/src/views/wallet/nfts/ShownNfts.tsx b/packages/suite/src/views/wallet/nfts/ShownNfts.tsx new file mode 100644 index 000000000000..2ef8366b42f8 --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/ShownNfts.tsx @@ -0,0 +1,59 @@ +import { Column } from '@trezor/components'; +import { SelectedAccountLoaded } from '@suite-common/wallet-types'; +import { selectNftDefinitions } from '@suite-common/token-definitions'; + +import { Translation } from 'src/components/suite'; +import { useSelector } from 'src/hooks/suite'; +import { getTokens } from 'src/utils/wallet/tokenUtils'; + +import { NoTokens } from '../tokens/common/NoTokens'; +import NftsTable from './NftsTable/NftsTable'; + +type ShownNftsProps = { + selectedAccount: SelectedAccountLoaded; + searchQuery: string; +}; + +export const ShownNfts = ({ selectedAccount, searchQuery }: ShownNftsProps) => { + const nftDefinitions = useSelector(state => + selectNftDefinitions(state, selectedAccount.account.symbol), + ); + const nfts = getTokens({ + tokens: selectedAccount.account.tokens || [], + symbol: selectedAccount.account.symbol, + tokenDefinitions: nftDefinitions, + isNft: true, + searchQuery, + }); + + return nfts?.shownWithBalance.length || nfts?.shownWithoutBalance.length ? ( + + + + + ) : ( + + } + /> + ); +}; diff --git a/packages/suite/src/views/wallet/nfts/index.tsx b/packages/suite/src/views/wallet/nfts/index.tsx new file mode 100644 index 000000000000..0d888927bbdd --- /dev/null +++ b/packages/suite/src/views/wallet/nfts/index.tsx @@ -0,0 +1,52 @@ +import { useState, useEffect } from 'react'; +import { Route, Switch } from 'react-router-dom'; + +import { WalletLayout } from 'src/components/wallet'; +import { useDispatch, useSelector } from 'src/hooks/suite'; +import { goto } from 'src/actions/suite/routerActions'; + +import { TokensNavigation } from '../tokens/TokensNavigation'; +import { HiddenNfts } from './HiddenNfts'; +import { ShownNfts } from './ShownNfts'; + +export const Nfts = () => { + const [searchQuery, setSearchQuery] = useState(''); + + const { selectedAccount } = useSelector(state => state.wallet); + + const dispatch = useDispatch(); + + useEffect(() => { + if ( + selectedAccount.status === 'loaded' && + !selectedAccount.network?.features.includes('nft') + ) { + dispatch(goto('wallet-index', { preserveParams: true })); + } + }, [selectedAccount, dispatch]); + + if (selectedAccount.status !== 'loaded') { + return ; + } + + return ( + + + + + + + + + + + + ); +}; + +export default Nfts; diff --git a/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx index 8971c57a6368..d233d48e5075 100644 --- a/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx +++ b/packages/suite/src/views/wallet/send/Outputs/TokenSelect/TokenSelect.tsx @@ -90,7 +90,11 @@ const buildTokenOptions = ( } if (accountTokens) { - const tokens = getTokens(accountTokens, symbol, coinDefinitions); + const tokens = getTokens({ + tokens: accountTokens, + symbol, + tokenDefinitions: coinDefinitions, + }); if (accountTokens && activeTokenTab === 'tokens') { tokens.shownWithBalance.forEach(token => diff --git a/packages/suite/src/views/wallet/tokens/TokensNavigation.tsx b/packages/suite/src/views/wallet/tokens/TokensNavigation.tsx index 8bb4cac4bc57..f252e4ef93fe 100644 --- a/packages/suite/src/views/wallet/tokens/TokensNavigation.tsx +++ b/packages/suite/src/views/wallet/tokens/TokensNavigation.tsx @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction, useEffect, useState } from 'react'; import { SelectedAccountLoaded } from '@suite-common/wallet-types'; -import { selectCoinDefinitions } from '@suite-common/token-definitions'; +import { selectCoinDefinitions, selectNftDefinitions } from '@suite-common/token-definitions'; import { spacings } from '@trezor/theme'; import { IconButton, Row } from '@trezor/components'; import { EventType, analytics } from '@trezor/suite-analytics'; @@ -18,12 +18,14 @@ interface TokensNavigationProps { selectedAccount: SelectedAccountLoaded; searchQuery: string; setSearchQuery: Dispatch>; + isNft: boolean; } export const TokensNavigation = ({ selectedAccount, searchQuery, setSearchQuery, + isNft = false, }: TokensNavigationProps) => { const { account } = selectedAccount; @@ -32,17 +34,20 @@ export const TokensNavigation = ({ const routeName = useSelector(selectRouteName); const coinDefinitions = useSelector(state => - selectCoinDefinitions(state, selectedAccount.account.symbol), + isNft + ? selectNftDefinitions(state, selectedAccount.account.symbol) + : selectCoinDefinitions(state, selectedAccount.account.symbol), ); const isDebug = useSelector(selectIsDebugModeActive); const dispatch = useDispatch(); - const tokens = getTokens( - selectedAccount.account.tokens || [], - selectedAccount.account.symbol, - coinDefinitions, - ); - const showAddToken = ['ethereum'].includes(account.networkType) && isDebug; + const tokens = getTokens({ + tokens: selectedAccount.account.tokens || [], + symbol: selectedAccount.account.symbol, + tokenDefinitions: coinDefinitions, + isNft, + }); + const showAddToken = ['ethereum'].includes(account.networkType) && isDebug && !isNft; const handleAddToken = () => { if (account.symbol) { @@ -64,9 +69,9 @@ export const TokensNavigation = ({ ); const sortedTokens = tokensWithRates.sort(sortTokensWithRates); - const tokens = getTokens(sortedTokens, account.symbol, coinDefinitions, searchQuery); + const tokens = getTokens({ + tokens: sortedTokens, + symbol: account.symbol, + tokenDefinitions: coinDefinitions, + searchQuery, + }); const hiddenTokensCount = tokens.unverifiedWithBalance.length + tokens.hiddenWithBalance.length + diff --git a/packages/suite/src/views/wallet/tokens/common/TokensTable/TokenRow.tsx b/packages/suite/src/views/wallet/tokens/common/TokensTable/TokenRow.tsx index a689103ef453..f98886e34313 100644 --- a/packages/suite/src/views/wallet/tokens/common/TokensTable/TokenRow.tsx +++ b/packages/suite/src/views/wallet/tokens/common/TokensTable/TokenRow.tsx @@ -1,5 +1,3 @@ -import styled from 'styled-components'; - import { selectDevice } from '@suite-common/wallet-core'; import { Account, TokenAddress } from '@suite-common/wallet-types'; import { Network, getCoingeckoId } from '@suite-common/wallet-config'; @@ -23,7 +21,7 @@ import { Column, Text, } from '@trezor/components'; -import { spacings, spacingsPx } from '@trezor/theme'; +import { spacings } from '@trezor/theme'; import { EventType, analytics } from '@trezor/suite-analytics'; import { getContractAddressForNetwork, getTokenExplorerUrl } from '@suite-common/wallet-utils'; @@ -61,18 +59,6 @@ import { setCoinmarketSellAccount } from 'src/actions/wallet/coinmarketSellActio import { BlurUrls } from '../BlurUrls'; -const ContractAddress = styled.div` - display: inline-block; - max-width: 200px; - word-break: break-all; - white-space: wrap; -`; - -const IconWrapper = styled.div` - display: inline-block; - margin-left: ${spacingsPx.xxs}; -`; - interface TokenRowProps { account: Account; token: EnhancedTokenInfo; @@ -348,12 +334,10 @@ export const TokenRow = ({ options: [ { label: ( - + {token.contract} - - - - + + ), onClick: () => dispatch( @@ -373,12 +357,10 @@ export const TokenRow = ({ options: [ { label: ( - + {token.fingerprint} - - - - + + ), onClick: () => token.fingerprint && @@ -399,12 +381,10 @@ export const TokenRow = ({ options: [ { label: ( - + {token.policyId} - - - - + + ), onClick: () => token.policyId && diff --git a/packages/suite/src/views/wallet/tokens/hidden-tokens/HiddenTokensTable.tsx b/packages/suite/src/views/wallet/tokens/hidden-tokens/HiddenTokensTable.tsx index 6fcc6f21e559..2251bd3bb97e 100644 --- a/packages/suite/src/views/wallet/tokens/hidden-tokens/HiddenTokensTable.tsx +++ b/packages/suite/src/views/wallet/tokens/hidden-tokens/HiddenTokensTable.tsx @@ -27,8 +27,17 @@ export const HiddenTokensTable = ({ selectedAccount, searchQuery }: HiddenTokens ) : []; - const filteredTokens = getTokens(sortedTokens, account.symbol, coinDefinitions, searchQuery); - const tokens = getTokens(sortedTokens, account.symbol, coinDefinitions); + const filteredTokens = getTokens({ + tokens: sortedTokens, + symbol: account.symbol, + tokenDefinitions: coinDefinitions, + searchQuery, + }); + const tokens = getTokens({ + tokens: sortedTokens, + symbol: account.symbol, + tokenDefinitions: coinDefinitions, + }); const hiddenTokensCount = tokens.hiddenWithBalance.length + tokens.hiddenWithoutBalance.length; const unverifiedTokensCount = diff --git a/suite-common/suite-config/src/routes.ts b/suite-common/suite-config/src/routes.ts index e420bca15a4a..8eb04dd51809 100644 --- a/suite-common/suite-config/src/routes.ts +++ b/suite-common/suite-config/src/routes.ts @@ -276,6 +276,20 @@ export const routes = [ params: walletParams, isNestedRoute: true, }, + { + name: 'wallet-nfts', + pattern: '/accounts/nfts', + app: 'wallet', + params: walletParams, + }, + { + name: 'wallet-nfts-hidden', + pattern: '/accounts/nfts/hidden', + app: 'wallet', + params: walletParams, + isNestedRoute: true, + }, + { name: 'wallet-anonymize', pattern: '/accounts/anonymize', diff --git a/suite-common/wallet-config/src/networksConfig.ts b/suite-common/wallet-config/src/networksConfig.ts index e232797a873c..6a331b07267c 100644 --- a/suite-common/wallet-config/src/networksConfig.ts +++ b/suite-common/wallet-config/src/networksConfig.ts @@ -59,6 +59,7 @@ export const networks = { 'rbf', 'sign-verify', 'tokens', + 'nft', 'coin-definitions', 'nft-definitions', 'staking', @@ -95,7 +96,7 @@ export const networks = { nft: 'https://pol1.trezor.io/nft/', address: 'https://pol1.trezor.io/address/', }, - features: ['rbf', 'sign-verify', 'tokens', 'coin-definitions', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'nft', 'coin-definitions', 'nft-definitions'], customBackends: ['blockbook'], accountTypes: { ledger: { @@ -122,7 +123,7 @@ export const networks = { nft: 'https://bsc1.trezor.io/nft/', address: 'https://bsc1.trezor.io/address/', }, - features: ['rbf', 'sign-verify', 'tokens', 'coin-definitions', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'nft', 'coin-definitions', 'nft-definitions'], customBackends: ['blockbook'], accountTypes: { ledger: { @@ -149,7 +150,7 @@ export const networks = { nft: 'https://basescan.org/nft/', address: 'https://basescan.org/address/', }, - features: ['rbf', 'sign-verify', 'tokens', 'coin-definitions', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'nft', 'coin-definitions', 'nft-definitions'], customBackends: ['blockbook'], accountTypes: { ledger: { @@ -177,7 +178,7 @@ export const networks = { nft: 'https://op1.trezor.io/nft/', address: 'https://op1.trezor.io/address/', }, - features: ['rbf', 'sign-verify', 'tokens', 'coin-definitions', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'nft', 'coin-definitions', 'nft-definitions'], customBackends: ['blockbook'], accountTypes: { ledger: { diff --git a/suite-common/wallet-config/src/types.ts b/suite-common/wallet-config/src/types.ts index 950d09bc98f6..647a9e494bc4 100644 --- a/suite-common/wallet-config/src/types.ts +++ b/suite-common/wallet-config/src/types.ts @@ -50,6 +50,7 @@ export type BackendType = TrezorConnectBackendType | NonStandardBackendType; export type NetworkFeature = | 'rbf' + | 'nft' | 'sign-verify' | 'amount-unit' | 'tokens' diff --git a/suite-common/wallet-utils/src/accountUtils.ts b/suite-common/wallet-utils/src/accountUtils.ts index cec5a92689ee..a5de30b9b411 100644 --- a/suite-common/wallet-utils/src/accountUtils.ts +++ b/suite-common/wallet-utils/src/accountUtils.ts @@ -44,6 +44,7 @@ import { toFiatCurrency } from './fiatConverterUtils'; import { getFiatRateKey } from './fiatRatesUtils'; import { getAccountTotalStakingBalance } from './ethereumStakingUtils'; import { isRbfTransaction } from './transactionUtils'; +import { Token } from '@trezor/blockchain-link-types/src/blockbook-api'; export const isUtxoBased = (account: Account) => account.networkType === 'bitcoin' || account.networkType === 'cardano'; @@ -1183,3 +1184,16 @@ export const isTokenMatchesSearch = (token: TokenInfo, search: string) => { token.policyId?.toLowerCase().includes(search) ); }; + +export const isNftMatchesSearch = (token: Token, search: string) => { + return ( + token.symbol?.toLowerCase().includes(search) || + token.name?.toLowerCase().includes(search) || + token.contract?.toLowerCase().includes(search) || + token.ids + ?.map(id => id.toString()) + .join(', ') + .includes(search) || + token.multiTokenValues?.some(value => value.id?.includes(search)) + ); +}; diff --git a/suite-common/wallet-utils/src/tokenUtils.ts b/suite-common/wallet-utils/src/tokenUtils.ts index bf4277007743..4ff45301327d 100644 --- a/suite-common/wallet-utils/src/tokenUtils.ts +++ b/suite-common/wallet-utils/src/tokenUtils.ts @@ -35,3 +35,10 @@ export const getTokenExplorerUrl = ( return `${explorerUrl}${contractAddress}${queryString}`; }; + +export const getNftExplorerUrl = (network: Network, nft: TokenInfo, id?: string) => { + const explorerUrl = network.explorer.nft; + const contractAddressWithId = nft.contract + `/${id}`; + + return `${explorerUrl}${contractAddressWithId}`; +};