1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-23 09:52:26 +01:00

Add modal with directions to re-add token as NFT (#13291)

* Add modal pop for NFTs previously added as tokens - with directions to re-add as NFTs
This commit is contained in:
Alex Donesky 2022-01-19 12:42:41 -06:00 committed by GitHub
parent 5b92dc4cf0
commit f087e501a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 383 additions and 123 deletions

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "Ιστορικό" "message": "Ιστορικό"
}, },
"id": {
"message": "Αναγνωριστικό"
},
"import": { "import": {
"message": "Εισαγωγή", "message": "Εισαγωγή",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -596,6 +596,9 @@
"contractInteraction": { "contractInteraction": {
"message": "Contract Interaction" "message": "Contract Interaction"
}, },
"convertTokenToNFTDescription": {
"message": "We've detected that this asset is an NFT. Metamask now has full native support for NFTs. Would you like to remove it from your token list and add it as an NFT?"
},
"copiedExclamation": { "copiedExclamation": {
"message": "Copied!" "message": "Copied!"
}, },
@ -1367,9 +1370,6 @@
"history": { "history": {
"message": "History" "message": "History"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Import", "message": "Import",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"
@ -1406,9 +1406,15 @@
"importNFT": { "importNFT": {
"message": "Import NFT" "message": "Import NFT"
}, },
"importNFTAddressToolTip": {
"message": "On OpenSea, for example, on the NFT's page under Details, there is a blue hyperlinked value labeled 'Contract Address'. If you click on this, it will take you to the contract's address on Etherscan; at the top-left of that page, there should be an icon labeled 'Contract', and to the right, a long string of letters and numbers. This is the address of the contract that created your NFT. Click on the 'copy' icon to the right of the address, and you'll have it on your clipboard."
},
"importNFTPage": { "importNFTPage": {
"message": "Import NFT page" "message": "Import NFT page"
}, },
"importNFTTokenIdToolTip": {
"message": "A collectible's ID is a unique identifier since no two NFTs are alike. Again, on OpenSea this number is under 'Details'. Make a note of it, or copy it onto your clipboard."
},
"importNFTs": { "importNFTs": {
"message": "Import NFTs" "message": "Import NFTs"
}, },
@ -1462,6 +1468,9 @@
"invalidAddressRecipientNotEthNetwork": { "invalidAddressRecipientNotEthNetwork": {
"message": "Not ETH network, set to lowercase" "message": "Not ETH network, set to lowercase"
}, },
"invalidAssetType": {
"message": "This asset is an NFT and needs to be re-added on the Import NFTs page found under the NFTs tab"
},
"invalidBlockExplorerURL": { "invalidBlockExplorerURL": {
"message": "Invalid Block Explorer URL" "message": "Invalid Block Explorer URL"
}, },
@ -1914,7 +1923,7 @@
"description": "The next nonce according to MetaMask's internal logic" "description": "The next nonce according to MetaMask's internal logic"
}, },
"nftTokenIdPlaceholder": { "nftTokenIdPlaceholder": {
"message": "Enter the collectible ID" "message": "Enter the Token ID"
}, },
"nfts": { "nfts": {
"message": "NFTs" "message": "NFTs"
@ -3531,6 +3540,9 @@
"message": "$1 of $2 pending", "message": "$1 of $2 pending",
"description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total" "description": "$1 and $2 are intended to be two numbers, where $2 is a total number of pending confirmations, and $1 is a count towards that total"
}, },
"yes": {
"message": "Yes"
},
"yesLetsTry": { "yesLetsTry": {
"message": "Yes, let's try" "message": "Yes, let's try"
}, },

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "Historique" "message": "Historique"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Importer", "message": "Importer",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "इतिहास" "message": "इतिहास"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "आयात करें", "message": "आयात करें",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "Riwayat" "message": "Riwayat"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Impor", "message": "Impor",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "履歴" "message": "履歴"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "インポート", "message": "インポート",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "기록" "message": "기록"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "가져오기", "message": "가져오기",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "История" "message": "История"
}, },
"id": {
"message": "Ид."
},
"import": { "import": {
"message": "Импорт", "message": "Импорт",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "History" "message": "History"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Mag-import", "message": "Mag-import",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "Geçmiş" "message": "Geçmiş"
}, },
"id": {
"message": "Kimlik"
},
"import": { "import": {
"message": "Al", "message": "Al",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "Lịch sử" "message": "Lịch sử"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "Nhập", "message": "Nhập",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -1306,9 +1306,6 @@
"history": { "history": {
"message": "历史记录" "message": "历史记录"
}, },
"id": {
"message": "ID"
},
"import": { "import": {
"message": "导入", "message": "导入",
"description": "Button to import an account from a selected file" "description": "Button to import an account from a selected file"

View File

@ -13,6 +13,7 @@ import { useMetricEvent } from '../../../hooks/useMetricEvent';
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send'; import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEND_ROUTE } from '../../../helpers/constants/routes';
import { SEVERITIES } from '../../../helpers/constants/design-system'; import { SEVERITIES } from '../../../helpers/constants/design-system';
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
const AssetListItem = ({ const AssetListItem = ({
className, className,
@ -65,21 +66,26 @@ const AssetListItem = ({
<Button <Button
type="link" type="link"
className="asset-list-item__send-token-button" className="asset-list-item__send-token-button"
onClick={(e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
sendTokenEvent(); sendTokenEvent();
dispatch( try {
updateSendAsset({ await dispatch(
type: ASSET_TYPES.TOKEN, updateSendAsset({
details: { type: ASSET_TYPES.TOKEN,
address: tokenAddress, details: {
decimals: tokenDecimals, address: tokenAddress,
symbol: tokenSymbol, decimals: tokenDecimals,
}, symbol: tokenSymbol,
}), },
).then(() => { }),
);
history.push(SEND_ROUTE); history.push(SEND_ROUTE);
}); } catch (err) {
if (!err.message.includes(INVALID_ASSET_TYPE)) {
throw err;
}
}
}} }}
> >
{t('sendSpecifiedTokens', [tokenSymbol])} {t('sendSpecifiedTokens', [tokenSymbol])}

View File

@ -20,7 +20,7 @@ export default function CollectiblesDetectionNotice() {
const history = useHistory(); const history = useHistory();
return ( return (
<Box marginBottom={8} className="collectibles-detection-notice"> <Box marginBottom={4} className="collectibles-detection-notice">
<Dialog type="message" className="collectibles-detection-notice__message"> <Dialog type="message" className="collectibles-detection-notice__message">
<button <button
onClick={() => setCollectiblesDetectionNoticeDismissed()} onClick={() => setCollectiblesDetectionNoticeDismissed()}

View File

@ -22,7 +22,7 @@
a.collectibles-detection-notice__message__link { a.collectibles-detection-notice__message__link {
@include H6; @include H6;
width: 60%; width: 100%;
padding: 0; padding: 0;
justify-content: flex-start; justify-content: flex-start;
font-weight: bold; font-weight: bold;

View File

@ -0,0 +1,46 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useHistory } from 'react-router-dom';
import Modal from '../../modal';
import Typography from '../../../ui/typography';
import { TYPOGRAPHY } from '../../../../helpers/constants/design-system';
import withModalProps from '../../../../helpers/higher-order-components/with-modal-props';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import { ADD_COLLECTIBLE_ROUTE } from '../../../../helpers/constants/routes';
const ConvertTokenToNFTModal = ({ hideModal, tokenAddress }) => {
const history = useHistory();
const t = useI18nContext();
return (
<Modal
onSubmit={() => {
history.push({
pathname: ADD_COLLECTIBLE_ROUTE,
state: { tokenAddress },
});
hideModal();
}}
submitText={t('yes')}
onCancel={() => hideModal()}
cancelText={t('cancel')}
>
<div className="convert-token-to-nft-modal">
<Typography
variant={TYPOGRAPHY.H6}
boxProps={{
marginTop: 2,
}}
>
{t('convertTokenToNFTDescription')}
</Typography>
</div>
</Modal>
);
};
ConvertTokenToNFTModal.propTypes = {
hideModal: PropTypes.func.isRequired,
tokenAddress: PropTypes.string,
};
export default withModalProps(ConvertTokenToNFTModal);

View File

@ -0,0 +1,4 @@
.convert-token-to-nft-modal {
display: flex;
flex-flow: column nowrap;
}

View File

@ -12,6 +12,7 @@
@import 'qr-scanner/index'; @import 'qr-scanner/index';
@import 'transaction-confirmed/index'; @import 'transaction-confirmed/index';
@import 'customize-nonce/index'; @import 'customize-nonce/index';
@import 'convert-token-to-nft-modal/index';
.modal { .modal {
z-index: 1050; z-index: 1050;

View File

@ -30,6 +30,7 @@ import AddToAddressBookModal from './add-to-addressbook-modal';
import EditApprovalPermission from './edit-approval-permission'; import EditApprovalPermission from './edit-approval-permission';
import NewAccountModal from './new-account-modal'; import NewAccountModal from './new-account-modal';
import CustomizeNonceModal from './customize-nonce'; import CustomizeNonceModal from './customize-nonce';
import ConvertTokenToNftModal from './convert-token-to-nft-modal/convert-token-to-nft-modal';
const modalContainerBaseStyle = { const modalContainerBaseStyle = {
transform: 'translate3d(-50%, 0, 0px)', transform: 'translate3d(-50%, 0, 0px)',
@ -237,6 +238,19 @@ const MODALS = {
}, },
}, },
CONVERT_TOKEN_TO_NFT: {
contents: <ConvertTokenToNftModal />,
mobileModalStyle: {
...modalContainerMobileStyle,
},
laptopModalStyle: {
...modalContainerLaptopStyle,
},
contentStyle: {
borderRadius: '8px',
},
},
CONFIRM_DELETE_NETWORK: { CONFIRM_DELETE_NETWORK: {
contents: <ConfirmDeleteNetwork />, contents: <ConfirmDeleteNetwork />,
mobileModalStyle: { mobileModalStyle: {

View File

@ -1,4 +1,4 @@
import React, { useContext } from 'react'; import React, { useContext, useEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
@ -29,6 +29,8 @@ import SwapIcon from '../../ui/icon/swap-icon.component';
import SendIcon from '../../ui/icon/overview-send-icon.component'; import SendIcon from '../../ui/icon/overview-send-icon.component';
import IconButton from '../../ui/icon-button'; import IconButton from '../../ui/icon-button';
import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys';
import { showModal } from '../../../store/actions';
import WalletOverview from './wallet-overview'; import WalletOverview from './wallet-overview';
const TokenOverview = ({ className, token }) => { const TokenOverview = ({ className, token }) => {
@ -59,6 +61,17 @@ const TokenOverview = ({ className, token }) => {
category: 'swaps', category: 'swaps',
}); });
useEffect(() => {
if (token.isERC721) {
dispatch(
showModal({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: token.address,
}),
);
}
}, [token.isERC721, token.address, dispatch]);
return ( return (
<WalletOverview <WalletOverview
balance={ balance={
@ -81,16 +94,21 @@ const TokenOverview = ({ className, token }) => {
<> <>
<IconButton <IconButton
className="token-overview__button" className="token-overview__button"
onClick={() => { onClick={async () => {
sendTokenEvent(); sendTokenEvent();
dispatch( try {
updateSendAsset({ await dispatch(
type: ASSET_TYPES.TOKEN, updateSendAsset({
details: token, type: ASSET_TYPES.TOKEN,
}), details: token,
).then(() => { }),
);
history.push(SEND_ROUTE); history.push(SEND_ROUTE);
}); } catch (err) {
if (!err.message.includes(INVALID_ASSET_TYPE)) {
throw err;
}
}
}} }}
Icon={SendIcon} Icon={SendIcon}
label={t('send')} label={t('send')}

View File

@ -0,0 +1,74 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { renderWithProvider } from '../../../../test/jest/rendering';
import TokenOverview from './token-overview';
describe('TokenOverview', () => {
const mockStore = {
metamask: {
provider: {
type: 'test',
},
preferences: {
useNativeCurrencyAsPrimaryCurrency: true,
},
identities: {
'0x1': {
address: '0x1',
},
},
selectedAddress: '0x1',
keyrings: [
{
type: 'HD Key Tree',
accounts: ['0x1', '0x2'],
},
{
type: 'Ledger Hardware',
accounts: [],
},
],
contractExchangeRates: {},
},
};
const store = configureMockStore([thunk])(mockStore);
afterEach(() => {
store.clearActions();
});
describe('TokenOverview', () => {
it('should not show a modal when token passed in props is not an ERC721', () => {
const token = {
name: 'test',
isERC721: false,
address: '0x01',
symbol: 'test',
};
renderWithProvider(<TokenOverview token={token} />, store);
const actions = store.getActions();
expect(actions).toHaveLength(0);
});
it('should show ConvertTokenToNFT modal when token passed in props is an ERC721', () => {
const token = {
name: 'test',
isERC721: true,
address: '0x01',
symbol: 'test',
};
renderWithProvider(<TokenOverview token={token} />, store);
const actions = store.getActions();
expect(actions).toHaveLength(1);
expect(actions[0].type).toBe('UI_MODAL_OPEN');
expect(actions[0].payload).toStrictEqual({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: '0x01',
});
});
});
});

View File

@ -30,6 +30,7 @@ export default function FormField({
password, password,
allowDecimals, allowDecimals,
disabled, disabled,
placeholder,
}) { }) {
return ( return (
<div <div
@ -85,6 +86,7 @@ export default function FormField({
allowDecimals={allowDecimals} allowDecimals={allowDecimals}
disabled={disabled} disabled={disabled}
dataTestId={dataTestId} dataTestId={dataTestId}
placeholder={placeholder}
/> />
) : ( ) : (
<input <input
@ -97,6 +99,7 @@ export default function FormField({
autoFocus={autoFocus} autoFocus={autoFocus}
disabled={disabled} disabled={disabled}
data-testid={dataTestId} data-testid={dataTestId}
placeholder={placeholder}
/> />
)} )}
{error && ( {error && (
@ -170,6 +173,10 @@ FormField.propTypes = {
* Check if the form disabled * Check if the form disabled
*/ */
disabled: PropTypes.bool, disabled: PropTypes.bool,
/**
* Set the placeholder text for the input field
*/
placeholder: PropTypes.string,
}; };
FormField.defaultProps = { FormField.defaultProps = {

View File

@ -30,6 +30,7 @@ export default {
password: { control: 'boolean' }, password: { control: 'boolean' },
allowDecimals: { control: 'boolean' }, allowDecimals: { control: 'boolean' },
disabled: { control: 'boolean' }, disabled: { control: 'boolean' },
placeholder: { control: 'text' },
}, },
}; };

View File

@ -15,6 +15,7 @@ export default function NumericInput({
allowDecimals = true, allowDecimals = true,
disabled = false, disabled = false,
dataTestId, dataTestId,
placeholder,
}) { }) {
return ( return (
<div <div
@ -40,6 +41,7 @@ export default function NumericInput({
autoFocus={autoFocus} autoFocus={autoFocus}
disabled={disabled} disabled={disabled}
data-testid={dataTestId} data-testid={dataTestId}
placeholder={placeholder}
/> />
{detailText && ( {detailText && (
<Typography color={COLORS.UI4} variant={TYPOGRAPHY.H7} tag="span"> <Typography color={COLORS.UI4} variant={TYPOGRAPHY.H7} tag="span">
@ -59,4 +61,5 @@ NumericInput.propTypes = {
allowDecimals: PropTypes.bool, allowDecimals: PropTypes.bool,
disabled: PropTypes.bool, disabled: PropTypes.bool,
dataTestId: PropTypes.string, dataTestId: PropTypes.string,
placeholder: PropTypes.string,
}; };

View File

@ -53,11 +53,12 @@ import {
hideLoadingIndication, hideLoadingIndication,
showConfTxPage, showConfTxPage,
showLoadingIndication, showLoadingIndication,
updateTokenType,
updateTransaction, updateTransaction,
addPollingTokenToAppState, addPollingTokenToAppState,
removePollingTokenFromAppState, removePollingTokenFromAppState,
isCollectibleOwner, isCollectibleOwner,
getTokenStandardAndDetails,
showModal,
} from '../../store/actions'; } from '../../store/actions';
import { setCustomGasLimit } from '../gas/gas.duck'; import { setCustomGasLimit } from '../gas/gas.duck';
import { import {
@ -91,9 +92,16 @@ import {
isValidHexAddress, isValidHexAddress,
} from '../../../shared/modules/hexstring-utils'; } from '../../../shared/modules/hexstring-utils';
import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network';
import { ERC20, ETH, GWEI } from '../../helpers/constants/common'; import {
ERC20,
ERC721,
ERC1155,
ETH,
GWEI,
} from '../../helpers/constants/common';
import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction'; import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction';
import { readAddressAsContract } from '../../../shared/modules/contract-utils'; import { readAddressAsContract } from '../../../shared/modules/contract-utils';
import { INVALID_ASSET_TYPE } from '../../helpers/constants/error-keys';
// typedefs // typedefs
/** /**
* @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction * @typedef {import('@reduxjs/toolkit').PayloadAction} PayloadAction
@ -607,6 +615,8 @@ export const initialState = {
// In the case of tokens, the address, decimals and symbol of the token // In the case of tokens, the address, decimals and symbol of the token
// will be included in details // will be included in details
details: null, details: null,
// error to display when there is an issue with the asset
error: null,
}, },
draftTransaction: { draftTransaction: {
// The metamask internal id of the transaction. Only populated in the EDIT // The metamask internal id of the transaction. Only populated in the EDIT
@ -736,6 +746,7 @@ const slice = createSlice({
state.amount.value = action.payload.amount; state.amount.value = action.payload.amount;
state.gas.error = null; state.gas.error = null;
state.amount.error = null; state.amount.error = null;
state.asset.error = null;
state.recipient.address = action.payload.address; state.recipient.address = action.payload.address;
state.recipient.nickname = action.payload.nickname; state.recipient.nickname = action.payload.nickname;
state.draftTransaction.id = action.payload.id; state.draftTransaction.id = action.payload.id;
@ -887,6 +898,7 @@ const slice = createSlice({
updateAsset: (state, action) => { updateAsset: (state, action) => {
state.asset.type = action.payload.type; state.asset.type = action.payload.type;
state.asset.balance = action.payload.balance; state.asset.balance = action.payload.balance;
state.asset.error = action.payload.error;
if ( if (
state.asset.type === ASSET_TYPES.TOKEN || state.asset.type === ASSET_TYPES.TOKEN ||
state.asset.type === ASSET_TYPES.COLLECTIBLE state.asset.type === ASSET_TYPES.COLLECTIBLE
@ -1146,7 +1158,7 @@ const slice = createSlice({
}, },
validateSendState: (state) => { validateSendState: (state) => {
switch (true) { switch (true) {
// 1 + 2. State is invalid when either gas or amount fields have errors // 1 + 2. State is invalid when either gas or amount or asset fields have errors
// 3. State is invalid if asset type is a token and the token details // 3. State is invalid if asset type is a token and the token details
// are unknown. // are unknown.
// 4. State is invalid if no recipient has been added // 4. State is invalid if no recipient has been added
@ -1156,6 +1168,7 @@ const slice = createSlice({
// 8. State is invalid if the selected asset is a ERC721 // 8. State is invalid if the selected asset is a ERC721
case Boolean(state.amount.error): case Boolean(state.amount.error):
case Boolean(state.gas.error): case Boolean(state.gas.error):
case Boolean(state.asset.error):
case state.asset.type === ASSET_TYPES.TOKEN && case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details === null: state.asset.details === null:
case state.stage === SEND_STAGES.ADD_RECIPIENT: case state.stage === SEND_STAGES.ADD_RECIPIENT:
@ -1166,10 +1179,6 @@ const slice = createSlice({
): ):
state.status = SEND_STATUSES.INVALID; state.status = SEND_STATUSES.INVALID;
break; break;
case state.asset.type === ASSET_TYPES.TOKEN &&
state.asset.details.isERC721 === true:
state.status = SEND_STATUSES.INVALID;
break;
default: default:
state.status = SEND_STATUSES.VALID; state.status = SEND_STATUSES.VALID;
// Recompute the draftTransaction object // Recompute the draftTransaction object
@ -1419,31 +1428,43 @@ export function updateSendAmount(amount) {
export function updateSendAsset({ type, details }) { export function updateSendAsset({ type, details }) {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const state = getState(); const state = getState();
let { balance } = state.send.asset; let { balance, error } = state.send.asset;
const userAddress = state.send.account.address ?? getSelectedAddress(state);
if (type === ASSET_TYPES.TOKEN) { if (type === ASSET_TYPES.TOKEN) {
// if changing to a token, get the balance from the network. The asset
// overview page and asset list on the wallet overview page contain
// send buttons that call this method before initialization occurs.
// When this happens we don't yet have an account.address so default to
// the currently active account. In addition its possible for the balance
// check to take a decent amount of time, so we display a loading
// indication so that that immediate feedback is displayed to the user.
await dispatch(showLoadingIndication());
balance = await getERC20Balance(
details,
state.send.account.address ?? getSelectedAddress(state),
);
// TODO remove along with migration of isERC721 tokens and stripping away this designation
if (details) { if (details) {
if (details.isERC721 === undefined) {
const updatedAssetDetails = await updateTokenType(details.address);
details.isERC721 = updatedAssetDetails.isERC721;
}
if (details.standard === undefined) { if (details.standard === undefined) {
details.standard = ERC20; await dispatch(showLoadingIndication());
const { standard } = await getTokenStandardAndDetails(
details.address,
userAddress,
);
if (standard === ERC721 || standard === ERC1155) {
await dispatch(hideLoadingIndication());
dispatch(
showModal({
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: details.address,
}),
);
error = INVALID_ASSET_TYPE;
throw new Error(error);
}
details.standard = standard;
} }
// if changing to a token, get the balance from the network. The asset
// overview page and asset list on the wallet overview page contain
// send buttons that call this method before initialization occurs.
// When this happens we don't yet have an account.address so default to
// the currently active account. In addition its possible for the balance
// check to take a decent amount of time, so we display a loading
// indication so that that immediate feedback is displayed to the user.
if (details.standard === ERC20) {
error = null;
balance = await getERC20Balance(details, userAddress);
}
await dispatch(hideLoadingIndication());
} }
await dispatch(hideLoadingIndication());
} else if (type === ASSET_TYPES.COLLECTIBLE) { } else if (type === ASSET_TYPES.COLLECTIBLE) {
let isCurrentOwner = true; let isCurrentOwner = true;
try { try {
@ -1452,16 +1473,17 @@ export function updateSendAsset({ type, details }) {
details.address, details.address,
details.tokenId, details.tokenId,
); );
} catch (error) { } catch (err) {
if (error.message.includes('Unable to verify ownership.')) { if (err.message.includes('Unable to verify ownership.')) {
// this would indicate that either our attempts to verify ownership failed because of network issues, // this would indicate that either our attempts to verify ownership failed because of network issues,
// or, somehow a token has been added to collectibles state with an incorrect chainId. // or, somehow a token has been added to collectibles state with an incorrect chainId.
} else { } else {
// Any other error is unexpected and should be surfaced. // Any other error is unexpected and should be surfaced.
dispatch(displayWarning(error.message)); dispatch(displayWarning(err.message));
} }
} }
if (isCurrentOwner) { if (isCurrentOwner) {
error = null;
balance = '0x1'; balance = '0x1';
} else { } else {
throw new Error( throw new Error(
@ -1469,12 +1491,13 @@ export function updateSendAsset({ type, details }) {
); );
} }
} else { } else {
error = null;
// if changing to native currency, get it from the account key in send // if changing to native currency, get it from the account key in send
// state which is kept in sync when accounts change. // state which is kept in sync when accounts change.
balance = state.send.account.balance; balance = state.send.account.balance;
} }
// update the asset in state which will re-run amount and gas validation // update the asset in state which will re-run amount and gas validation
await dispatch(actions.updateAsset({ type, details, balance })); await dispatch(actions.updateAsset({ type, details, balance, error }));
await dispatch(computeEstimatedGasLimit()); await dispatch(computeEstimatedGasLimit());
}; };
} }
@ -1870,7 +1893,6 @@ export function getGasInputMode(state) {
} }
// Asset Selectors // Asset Selectors
export function getSendAsset(state) { export function getSendAsset(state) {
return state[name].asset; return state[name].asset;
} }
@ -1886,6 +1908,10 @@ export function getIsAssetSendable(state) {
return state[name].asset.details.isERC721 === false; return state[name].asset.details.isERC721 === false;
} }
export function getAssetError(state) {
return state[name].asset.error;
}
// Amount Selectors // Amount Selectors
export function getSendAmount(state) { export function getSendAmount(state) {
return state[name].amount.value; return state[name].amount.value;

View File

@ -19,6 +19,7 @@ import {
TRANSACTION_ENVELOPE_TYPES, TRANSACTION_ENVELOPE_TYPES,
TRANSACTION_TYPES, TRANSACTION_TYPES,
} from '../../../shared/constants/transaction'; } from '../../../shared/constants/transaction';
import * as Actions from '../../store/actions';
import sendReducer, { import sendReducer, {
initialState, initialState,
initializeSendState, initializeSendState,
@ -67,17 +68,6 @@ import sendReducer, {
const mockStore = createMockStore([thunk]); const mockStore = createMockStore([thunk]);
jest.mock('../../store/actions', () => {
const actual = jest.requireActual('../../store/actions');
return {
...actual,
estimateGas: jest.fn(() => Promise.resolve('0x0')),
getGasFeeEstimatesAndStartPolling: jest.fn(() => Promise.resolve()),
updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })),
isCollectibleOwner: jest.fn(() => Promise.resolve(true)),
};
});
jest.mock('./send', () => { jest.mock('./send', () => {
const actual = jest.requireActual('./send'); const actual = jest.requireActual('./send');
return { return {
@ -88,6 +78,25 @@ jest.mock('./send', () => {
}); });
describe('Send Slice', () => { describe('Send Slice', () => {
let getTokenStandardAndDetailsStub;
beforeEach(() => {
getTokenStandardAndDetailsStub = jest
.spyOn(Actions, 'getTokenStandardAndDetails')
.mockImplementation(() => Promise.resolve({ standard: 'ERC20' }));
jest
.spyOn(Actions, 'estimateGas')
.mockImplementation(() => Promise.resolve('0x0'));
jest
.spyOn(Actions, 'getGasFeeEstimatesAndStartPolling')
.mockImplementation(() => Promise.resolve());
jest
.spyOn(Actions, 'updateTokenType')
.mockImplementation(() => Promise.resolve({ isERC721: false }));
jest
.spyOn(Actions, 'isCollectibleOwner')
.mockImplementation(() => Promise.resolve(true));
});
describe('Reducers', () => { describe('Reducers', () => {
describe('updateSendAmount', () => { describe('updateSendAmount', () => {
it('should', async () => { it('should', async () => {
@ -1457,6 +1466,7 @@ describe('Send Slice', () => {
expect(actionResult[0].payload).toStrictEqual({ expect(actionResult[0].payload).toStrictEqual({
...newSendAsset, ...newSendAsset,
balance: '', balance: '',
error: null,
}); });
expect(actionResult[1].type).toStrictEqual( expect(actionResult[1].type).toStrictEqual(
@ -1499,6 +1509,7 @@ describe('Send Slice', () => {
expect(actionResult[2].payload).toStrictEqual({ expect(actionResult[2].payload).toStrictEqual({
...newSendAsset, ...newSendAsset,
balance: '0x0', balance: '0x0',
error: null,
}); });
expect(actionResult[3].type).toStrictEqual( expect(actionResult[3].type).toStrictEqual(
@ -1511,6 +1522,37 @@ describe('Send Slice', () => {
'send/computeEstimatedGasLimit/fulfilled', 'send/computeEstimatedGasLimit/fulfilled',
); );
}); });
it('should show ConvertTokenToNFT modal and throw "invalidAssetType" error when token passed in props is an ERC721 or ERC1155', async () => {
getTokenStandardAndDetailsStub.mockImplementation(() =>
Promise.resolve({ standard: 'ERC1155' }),
);
const store = mockStore(defaultSendAssetState);
const newSendAsset = {
type: ASSET_TYPES.TOKEN,
details: {
address: 'tokenAddress',
symbol: 'tokenSymbol',
decimals: 'tokenDecimals',
},
};
await expect(() =>
store.dispatch(updateSendAsset(newSendAsset)),
).rejects.toThrow('invalidAssetType');
const actionResult = store.getActions();
expect(actionResult).toHaveLength(3);
expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION');
expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION');
expect(actionResult[2]).toStrictEqual({
payload: {
name: 'CONVERT_TOKEN_TO_NFT',
tokenAddress: 'tokenAddress',
},
type: 'UI_MODAL_OPEN',
});
});
}); });
describe('updateRecipientUserInput', () => { describe('updateRecipientUserInput', () => {
@ -2231,6 +2273,7 @@ describe('Send Slice', () => {
expect(actionResult[0].payload).toStrictEqual({ expect(actionResult[0].payload).toStrictEqual({
balance: '0x1', balance: '0x1',
type: ASSET_TYPES.COLLECTIBLE, type: ASSET_TYPES.COLLECTIBLE,
error: null,
details: { details: {
address: '0xTokenAddress', address: '0xTokenAddress',
description: 'A test NFT dispensed from faucet.paradigm.xyz.', description: 'A test NFT dispensed from faucet.paradigm.xyz.',
@ -2361,11 +2404,11 @@ describe('Send Slice', () => {
expect(actionResult[2].payload).toStrictEqual({ expect(actionResult[2].payload).toStrictEqual({
balance: '0x0', balance: '0x0',
type: ASSET_TYPES.TOKEN, type: ASSET_TYPES.TOKEN,
error: null,
details: { details: {
address: '0xTokenAddress', address: '0xTokenAddress',
decimals: 18, decimals: 18,
symbol: 'SYMB', symbol: 'SYMB',
isERC721: false,
standard: 'ERC20', standard: 'ERC20',
}, },
}); });

View File

@ -7,6 +7,7 @@ export const SECONDARY = 'SECONDARY';
export const ERC20 = 'ERC20'; export const ERC20 = 'ERC20';
export const ERC721 = 'ERC721'; export const ERC721 = 'ERC721';
export const ERC1155 = 'ERC1155';
export const GAS_ESTIMATE_TYPES = { export const GAS_ESTIMATE_TYPES = {
SLOW: 'SLOW', SLOW: 'SLOW',

View File

@ -7,3 +7,4 @@ export const GAS_PRICE_FETCH_FAILURE_ERROR_KEY = 'gasPriceFetchFailed';
export const GAS_PRICE_EXCESSIVE_ERROR_KEY = 'gasPriceExcessive'; export const GAS_PRICE_EXCESSIVE_ERROR_KEY = 'gasPriceExcessive';
export const UNSENDABLE_ASSET_ERROR_KEY = 'unsendableAsset'; export const UNSENDABLE_ASSET_ERROR_KEY = 'unsendableAsset';
export const INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY = 'insufficientFundsForGas'; export const INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY = 'insufficientFundsForGas';
export const INVALID_ASSET_TYPE = 'invalidAssetType';

View File

@ -17,6 +17,8 @@ import {
getShouldShowFiat, getShouldShowFiat,
getPreferences, getPreferences,
txDataSelector, txDataSelector,
getCurrentKeyring,
getTokenExchangeRates,
} from '../../selectors'; } from '../../selectors';
import { ETH } from '../../helpers/constants/common'; import { ETH } from '../../helpers/constants/common';
@ -140,6 +142,12 @@ export const generateUseSelectorRouter = ({
if (selector === getEIP1559V2Enabled) { if (selector === getEIP1559V2Enabled) {
return eip1559V2Enabled; return eip1559V2Enabled;
} }
if (selector === getCurrentKeyring) {
return { type: '' };
}
if (selector === getTokenExchangeRates) {
return { '0x1': '1' };
}
return undefined; return undefined;
}; };

View File

@ -1,41 +1,60 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useHistory } from 'react-router-dom'; import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { util } from '@metamask/controllers'; import { util } from '@metamask/controllers';
import { useI18nContext } from '../../hooks/useI18nContext'; import { useI18nContext } from '../../hooks/useI18nContext';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes'; import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
import Box from '../../components/ui/box'; import Box from '../../components/ui/box';
import TextField from '../../components/ui/text-field';
import PageContainer from '../../components/ui/page-container'; import PageContainer from '../../components/ui/page-container';
import { import {
addCollectibleVerifyOwnership, addCollectibleVerifyOwnership,
removeToken,
setNewCollectibleAddedMessage, setNewCollectibleAddedMessage,
} from '../../store/actions'; } from '../../store/actions';
import FormField from '../../components/ui/form-field';
import { getIsMainnet, getUseCollectibleDetection } from '../../selectors';
import { getCollectiblesDetectionNoticeDismissed } from '../../ducks/metamask/metamask';
import CollectiblesDetectionNotice from '../../components/app/collectibles-detection-notice';
export default function AddCollectible() { export default function AddCollectible() {
const t = useI18nContext(); const t = useI18nContext();
const history = useHistory(); const history = useHistory();
const dispatch = useDispatch(); const dispatch = useDispatch();
const useCollectibleDetection = useSelector(getUseCollectibleDetection);
const isMainnet = useSelector(getIsMainnet);
const collectibleDetectionNoticeDismissed = useSelector(
getCollectiblesDetectionNoticeDismissed,
);
const addressEnteredOnImportTokensPage = const addressEnteredOnImportTokensPage =
history?.location?.state?.addressEnteredOnImportTokensPage; history?.location?.state?.addressEnteredOnImportTokensPage;
const contractAddressToConvertFromTokenToCollectible =
history?.location?.state?.tokenAddress;
const [address, setAddress] = useState( const [address, setAddress] = useState(
addressEnteredOnImportTokensPage ?? '', addressEnteredOnImportTokensPage ??
contractAddressToConvertFromTokenToCollectible ??
'',
); );
const [tokenId, setTokenId] = useState(''); const [tokenId, setTokenId] = useState('');
const [disabled, setDisabled] = useState(true); const [disabled, setDisabled] = useState(true);
const handleAddCollectible = async () => { const handleAddCollectible = async () => {
try { try {
await dispatch(addCollectibleVerifyOwnership(address, tokenId)); await dispatch(
addCollectibleVerifyOwnership(address, tokenId.toString()),
);
} catch (error) { } catch (error) {
const { message } = error; const { message } = error;
dispatch(setNewCollectibleAddedMessage(message)); dispatch(setNewCollectibleAddedMessage(message));
history.push(DEFAULT_ROUTE); history.push(DEFAULT_ROUTE);
return; return;
} }
if (contractAddressToConvertFromTokenToCollectible) {
await dispatch(
removeToken(contractAddressToConvertFromTokenToCollectible),
);
}
dispatch(setNewCollectibleAddedMessage('success')); dispatch(setNewCollectibleAddedMessage('success'));
history.push(DEFAULT_ROUTE); history.push(DEFAULT_ROUTE);
}; };
@ -66,29 +85,33 @@ export default function AddCollectible() {
disabled={disabled} disabled={disabled}
contentComponent={ contentComponent={
<Box padding={4}> <Box padding={4}>
{isMainnet &&
!useCollectibleDetection &&
!collectibleDetectionNoticeDismissed ? (
<CollectiblesDetectionNotice />
) : null}
<Box> <Box>
<TextField <FormField
id="address" id="address"
label={t('address')} titleText={t('address')}
placeholder="0x..." placeholder="0x..."
type="text"
value={address} value={address}
onChange={(e) => validateAndSetAddress(e.target.value)} onChange={(val) => validateAndSetAddress(val)}
fullWidth tooltipText={t('importNFTAddressToolTip')}
autoFocus autoFocus
margin="normal"
/> />
</Box> </Box>
<Box> <Box>
<TextField <FormField
id="token-id" id="token-id"
label={t('id')} titleText={t('tokenId')}
placeholder={t('nftTokenIdPlaceholder')} placeholder={t('nftTokenIdPlaceholder')}
type="number"
value={tokenId} value={tokenId}
onChange={(e) => validateAndSetTokenId(e.target.value)} onChange={(val) => {
fullWidth validateAndSetTokenId(val);
margin="normal" }}
tooltipText={t('importNFTTokenIdToolTip')}
numeric
/> />
</Box> </Box>
</Box> </Box>

View File

@ -21,7 +21,7 @@ const Asset = () => {
const collectible = collectibles.find( const collectible = collectibles.find(
({ address, tokenId }) => ({ address, tokenId }) =>
isEqualCaseInsensitive(address, asset) && id === tokenId, isEqualCaseInsensitive(address, asset) && id === tokenId.toString(),
); );
useEffect(() => { useEffect(() => {

View File

@ -216,7 +216,7 @@ class ImportToken extends Component {
const isMainnetNetwork = this.props.chainId === '0x1'; const isMainnetNetwork = this.props.chainId === '0x1';
let standard; let standard;
if (addressIsValid) { if (addressIsValid && process.env.COLLECTIBLES_V1) {
try { try {
({ standard } = await this.props.getTokenStandardAndDetails( ({ standard } = await this.props.getTokenStandardAndDetails(
standardAddress, standardAddress,

View File

@ -37,6 +37,7 @@ export default class SendContent extends Component {
getIsBalanceInsufficient: PropTypes.bool, getIsBalanceInsufficient: PropTypes.bool,
asset: PropTypes.object, asset: PropTypes.object,
to: PropTypes.string, to: PropTypes.string,
assetError: PropTypes.string,
}; };
render() { render() {
@ -49,6 +50,7 @@ export default class SendContent extends Component {
networkOrAccountNotSupports1559, networkOrAccountNotSupports1559,
getIsBalanceInsufficient, getIsBalanceInsufficient,
asset, asset,
assetError,
} = this.props; } = this.props;
let gasError; let gasError;
@ -67,6 +69,7 @@ export default class SendContent extends Component {
return ( return (
<PageContainerContent> <PageContainerContent>
<div className="send-v2__form"> <div className="send-v2__form">
{assetError ? this.renderError(assetError) : null}
{gasError ? this.renderError(gasError) : null} {gasError ? this.renderError(gasError) : null}
{isEthGasPrice {isEthGasPrice
? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY) ? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)

View File

@ -10,6 +10,7 @@ import {
getIsBalanceInsufficient, getIsBalanceInsufficient,
getSendTo, getSendTo,
getSendAsset, getSendAsset,
getAssetError,
} from '../../../ducks/send'; } from '../../../ducks/send';
import SendContent from './send-content.component'; import SendContent from './send-content.component';
@ -32,6 +33,7 @@ function mapStateToProps(state) {
), ),
getIsBalanceInsufficient: getIsBalanceInsufficient(state), getIsBalanceInsufficient: getIsBalanceInsufficient(state),
asset: getSendAsset(state), asset: getSendAsset(state),
assetError: getAssetError(state),
}; };
} }