diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index a948be94930b..083c342e98a1 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2567,6 +2567,9 @@ "message": "This token is an NFT. Add on the $1", "description": "$1 is a clickable link with text defined by the 'importNFTPage' key" }, + "nftAlreadyAdded": { + "message": "NFT has already been added." + }, "nftDisclaimer": { "message": "Disclaimer: MetaMask pulls the media file from the source url. This url sometimes is changed by the marketplace the NFT was minted on." }, diff --git a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js index df02ed6c8c79..91c86348fe48 100644 --- a/ui/components/multichain/import-nfts-modal/import-nfts-modal.js +++ b/ui/components/multichain/import-nfts-modal/import-nfts-modal.js @@ -53,6 +53,8 @@ import { BannerAlert, } from '../../component-library'; import Tooltip from '../../ui/tooltip'; +import { useNftsCollections } from '../../../hooks/useNftsCollections'; +import { tokenIdExist } from '../../../helpers/utils/util'; export const ImportNftsModal = ({ onClose }) => { const t = useI18nContext(); @@ -68,12 +70,14 @@ export const ImportNftsModal = ({ onClose }) => { tokenId: initialTokenId, ignoreErc20Token, } = useSelector((state) => state.appState.importNftsModal); + const existingNfts = useNftsCollections(); const [nftAddress, setNftAddress] = useState(initialTokenAddress ?? ''); const [tokenId, setTokenId] = useState(initialTokenId ?? ''); const [disabled, setDisabled] = useState(true); const [nftAddFailed, setNftAddFailed] = useState(false); const trackEvent = useContext(MetaMetricsContext); + const [duplicateTokenIdError, setDuplicateTokenIdError] = useState(null); const handleAddNft = async () => { try { @@ -135,7 +139,23 @@ export const ImportNftsModal = ({ onClose }) => { }; const validateAndSetTokenId = (val) => { - setDisabled(!isValidHexAddress(nftAddress) || !val || isNaN(Number(val))); + setDuplicateTokenIdError(null); + // Check if tokenId is already imported + const tokenIdExists = tokenIdExist( + nftAddress, + val, + existingNfts.collections, + ); + if (tokenIdExists) { + setDuplicateTokenIdError(t('nftAlreadyAdded')); + } + setDisabled( + !isValidHexAddress(nftAddress) || + !val || + isNaN(Number(val)) || + tokenIdExists, + ); + setTokenId(val); }; @@ -243,6 +263,8 @@ export const ImportNftsModal = ({ onClose }) => { validateAndSetTokenId(e.target.value); setNftAddFailed(false); }} + helpText={duplicateTokenIdError} + error={duplicateTokenIdError} /> diff --git a/ui/helpers/utils/util.js b/ui/helpers/utils/util.js index 20ba3656b39d..5d378c2264fd 100644 --- a/ui/helpers/utils/util.js +++ b/ui/helpers/utils/util.js @@ -10,7 +10,7 @@ import bowser from 'bowser'; ///: BEGIN:ONLY_INCLUDE_IN(snaps) import { getSnapPrefix } from '@metamask/snaps-utils'; import { WALLET_SNAP_PERMISSION_KEY } from '@metamask/rpc-methods'; -import { isObject } from '@metamask/utils'; +import { isObject, isStrictHexString } from '@metamask/utils'; ///: END:ONLY_INCLUDE_IN import { CHAIN_IDS, NETWORK_TYPES } from '../../../shared/constants/network'; import { @@ -30,7 +30,6 @@ import { SNAPS_METADATA, } from '../../../shared/constants/snaps'; ///: END:ONLY_INCLUDE_IN - // formatData :: ( date: ) -> String export function formatDate(date, format = "M/d/y 'at' T") { if (!date) { @@ -631,3 +630,40 @@ export const getNetworkNameFromProviderType = (providerName) => { export const isAbleToExportAccount = (keyringType = '') => { return !keyringType.includes('Hardware') && !keyringType.includes('Snap'); }; + +/** + * Checks if a tokenId in Hex or decimal format already exists in an object. + * + * @param {*} address - collection address. + * @param {*} tokId - tokenId to search for + * @param {*} obj - object to look into + * @returns {boolean} `false` if tokenId does not already exist. + */ +export const tokenIdExist = (address, tokId, obj) => { + // check if input tokenId is hexadecimal + // If it is convert to decimal and compare with existing tokens + // if it is decimal convert to hexadecimal and compare + const isHex = isStrictHexString(tokId); + let convertedTokenId; + if (isHex) { + // Convert to decimal + convertedTokenId = parseInt(tokId, 16); + } else { + // Convert to hex + const decimalNumber = parseInt(tokId, 10); // 10 is the base for decimal + convertedTokenId = `0x${decimalNumber.toString(16)}`; + } + + if (obj[address]) { + const value = obj[address]; + return lodash.some(value.nfts, (nft) => { + return ( + nft.address === address && + (nft.tokenId.toLowerCase() === tokId.toLowerCase() || + nft.tokenId.toLowerCase() === + convertedTokenId.toString().toLowerCase()) + ); + }); + } + return false; +}; diff --git a/ui/helpers/utils/util.test.js b/ui/helpers/utils/util.test.js index 77ac796c2d9d..b4ae16d402f6 100644 --- a/ui/helpers/utils/util.test.js +++ b/ui/helpers/utils/util.test.js @@ -929,4 +929,72 @@ describe('util', () => { expect(util.getNetworkNameFromProviderType('rpc')).toStrictEqual(''); }); }); + + describe('tokenIdExist()', () => { + const data = { + '0x2df920B180c58766951395c26ecF1EC2063490Fa': { + collectionName: 'Numbers', + nfts: [ + { + address: '0x2df920B180c58766951395c26ecF1EC2063490Fa', + description: 'Numbers', + favorite: false, + name: 'Numbers #132', + tokenId: '132', + }, + ], + }, + '0x2df920B180c58766951395c26ecF1EC206343334': { + collectionName: 'toto', + nfts: [ + { + address: '0x2df920B180c58766951395c26ecF1EC206343334', + description: 'toto', + favorite: false, + name: 'toto#3453', + tokenId: '3453', + }, + ], + }, + }; + it('should return true if it exists', () => { + expect( + util.tokenIdExist( + '0x2df920B180c58766951395c26ecF1EC206343334', + '3456', + data, + ), + ).toBeTruthy(); + }); + + it('should return true if it exists in decimal format', () => { + expect( + util.tokenIdExist( + '0x2df920B180c58766951395c26ecF1EC206343334', + '0xD80', + data, + ), + ).toBeTruthy(); + }); + + it('should return false if it does not exists', () => { + expect( + util.tokenIdExist( + '0x2df920B180c58766951395c26ecF1EC206343334', + '1122', + data, + ), + ).toBeFalsy(); + }); + + it('should return false if it address does not exists', () => { + expect( + util.tokenIdExist( + '0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57', + '1122', + data, + ), + ).toBeFalsy(); + }); + }); });