1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-12-22 17:33:23 +01:00

Feat/collectibles send flow (#13048)

* Add collectibles send flow
This commit is contained in:
Alex Donesky 2022-01-10 10:23:53 -06:00 committed by GitHub
parent 0d1e79dda5
commit 4826c8c95e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
46 changed files with 1170 additions and 272 deletions

View File

@ -2531,6 +2531,9 @@
"sendTokens": {
"message": "Send Tokens"
},
"sendingDisabled": {
"message": "Sending of ERC-1155 NFT assets is not yet supported."
},
"sendingNativeAsset": {
"message": "Sending $1",
"description": "$1 represents the native currency symbol for the current network (e.g. ETH or BNB)"
@ -3154,6 +3157,9 @@
"tokenDetectionAnnouncement": {
"message": "New! Improved token detection is available on Ethereum Mainnet as an experimental feature. $1"
},
"tokenId": {
"message": "Token ID"
},
"tokenSymbol": {
"message": "Token Symbol"
},

View File

@ -44,6 +44,7 @@ import {
} from '../../../../shared/constants/network';
import { isEIP1559Transaction } from '../../../../shared/modules/transaction.utils';
import { readAddressAsContract } from '../../../../shared/modules/contract-utils';
import { isEqualCaseInsensitive } from '../../../../ui/helpers/utils/util';
import TransactionStateManager from './tx-state-manager';
import TxGasUtil from './tx-gas-utils';
import PendingTransactionTracker from './pending-tx-tracker';
@ -1287,7 +1288,7 @@ export default class TransactionController extends EventEmitter {
TRANSACTION_TYPES.TOKEN_METHOD_APPROVE,
TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER,
TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM,
].find((methodName) => methodName === name && name.toLowerCase());
].find((methodName) => isEqualCaseInsensitive(methodName, name));
let result;
if (data && tokenMethodName) {

View File

@ -40,7 +40,10 @@ import {
SubjectMetadataController,
} from '@metamask/snap-controllers';
import { TRANSACTION_STATUSES } from '../../shared/constants/transaction';
import {
TRANSACTION_STATUSES,
TRANSACTION_TYPES,
} from '../../shared/constants/transaction';
import {
GAS_API_BASE_URL,
GAS_DEV_API_BASE_URL,
@ -64,6 +67,9 @@ import {
} from '../../shared/constants/app';
import { hexToDecimal } from '../../ui/helpers/utils/conversions.util';
import { getTokenValueParam } from '../../ui/helpers/utils/token-util';
import { getTransactionData } from '../../ui/helpers/utils/transactions.util';
import { isEqualCaseInsensitive } from '../../ui/helpers/utils/util';
import ComposableObservableStore from './lib/ComposableObservableStore';
import AccountTracker from './lib/account-tracker';
import createLoggerMiddleware from './lib/createLoggerMiddleware';
@ -599,6 +605,43 @@ export default class MetamaskController extends EventEmitter {
this.platform.showTransactionNotification(txMeta, rpcPrefs);
const { txReceipt } = txMeta;
// if this is a transferFrom method generated from within the app it may be a collectible transfer transaction
// in which case we will want to check and update ownership status of the transferred collectible.
if (
txMeta.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM &&
txMeta.txParams !== undefined
) {
const {
data,
to: contractAddress,
from: userAddress,
} = txMeta.txParams;
const { chainId } = txMeta;
const transactionData = getTransactionData(data);
const tokenAmountOrTokenId = getTokenValueParam(transactionData);
const { allCollectibles } = this.collectiblesController.state;
// check if its a known collectible
const knownCollectible = allCollectibles?.[userAddress]?.[
chainId
].find(
({ address, tokenId }) =>
isEqualCaseInsensitive(address, contractAddress) &&
tokenId === tokenAmountOrTokenId,
);
// if it is we check and update ownership status.
if (knownCollectible) {
this.collectiblesController.checkAndUpdateSingleCollectibleOwnershipStatus(
knownCollectible,
false,
// TODO add this when checkAndUpdateSingleCollectibleOwnershipStatus is updated
// { userAddress, chainId },
);
}
}
const metamaskState = await this.getState();
if (txReceipt && txReceipt.status === '0x0') {
@ -1163,7 +1206,15 @@ export default class MetamaskController extends EventEmitter {
collectiblesController,
),
checkAndUpdateCollectiblesOwnershipStatus: collectiblesController.checkAndUpdateCollectiblesOwnershipStatus.bind(
checkAndUpdateAllCollectiblesOwnershipStatus: collectiblesController.checkAndUpdateAllCollectiblesOwnershipStatus.bind(
collectiblesController,
),
checkAndUpdateSingleCollectibleOwnershipStatus: collectiblesController.checkAndUpdateSingleCollectibleOwnershipStatus.bind(
collectiblesController,
),
isCollectibleOwner: collectiblesController.isCollectibleOwner.bind(
collectiblesController,
),

View File

@ -547,6 +547,7 @@
"@ethereumjs/common": true,
"@ethereumjs/tx": true,
"@metamask/contract-metadata": true,
"@metamask/metamask-eth-abis": true,
"abort-controller": true,
"async-mutex": true,
"buffer": true,
@ -565,7 +566,6 @@
"ethjs-util": true,
"events": true,
"human-standard-collectible-abi": true,
"human-standard-multi-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,

View File

@ -547,6 +547,7 @@
"@ethereumjs/common": true,
"@ethereumjs/tx": true,
"@metamask/contract-metadata": true,
"@metamask/metamask-eth-abis": true,
"abort-controller": true,
"async-mutex": true,
"buffer": true,
@ -565,7 +566,6 @@
"ethjs-util": true,
"events": true,
"human-standard-collectible-abi": true,
"human-standard-multi-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,

View File

@ -547,6 +547,7 @@
"@ethereumjs/common": true,
"@ethereumjs/tx": true,
"@metamask/contract-metadata": true,
"@metamask/metamask-eth-abis": true,
"abort-controller": true,
"async-mutex": true,
"buffer": true,
@ -565,7 +566,6 @@
"ethjs-util": true,
"events": true,
"human-standard-collectible-abi": true,
"human-standard-multi-collectible-abi": true,
"human-standard-token-abi": true,
"immer": true,
"isomorphic-fetch": true,

View File

@ -1052,6 +1052,16 @@
"buffer-equal": true
}
},
"are-we-there-yet": {
"builtin": {
"events.EventEmitter": true,
"util.inherits": true
},
"packages": {
"delegates": true,
"readable-stream": true
}
},
"arr-diff": {
"packages": {
"arr-flatten": true,
@ -1460,6 +1470,7 @@
"anymatch": true,
"async-each": true,
"braces": true,
"fsevents": true,
"glob-parent": true,
"inherits": true,
"is-binary-path": true,
@ -1726,6 +1737,16 @@
"through2": true
}
},
"detect-libc": {
"builtin": {
"child_process.spawnSync": true,
"fs.readdirSync": true,
"os.platform": true
},
"globals": {
"process.env": true
}
},
"detective": {
"packages": {
"acorn-node": true,
@ -2429,6 +2450,45 @@
"process.version": true
}
},
"fsevents": {
"builtin": {
"events.EventEmitter": true,
"fs.stat": true,
"path.join": true,
"util.inherits": true
},
"globals": {
"__dirname": true,
"process.nextTick": true,
"process.platform": true,
"setImmediate": true
},
"native": true,
"packages": {
"node-pre-gyp": true
}
},
"gauge": {
"builtin": {
"util.format": true
},
"globals": {
"clearInterval": true,
"process": true,
"setImmediate": true,
"setInterval": true
},
"packages": {
"aproba": true,
"console-control-strings": true,
"has-unicode": true,
"object-assign": true,
"signal-exit": true,
"string-width": true,
"strip-ansi": true,
"wide-align": true
}
},
"get-assigned-identifiers": {
"builtin": {
"assert.equal": true
@ -2807,6 +2867,16 @@
"process.argv": true
}
},
"has-unicode": {
"builtin": {
"os.type": true
},
"globals": {
"process.env.LANG": true,
"process.env.LC_ALL": true,
"process.env.LC_CTYPE": true
}
},
"has-value": {
"packages": {
"get-value": true,
@ -2978,6 +3048,11 @@
"is-plain-object": true
}
},
"is-fullwidth-code-point": {
"packages": {
"number-is-nan": true
}
},
"is-glob": {
"packages": {
"is-extglob": true
@ -3508,6 +3583,56 @@
"setTimeout": true
}
},
"node-pre-gyp": {
"builtin": {
"events.EventEmitter": true,
"fs.existsSync": true,
"fs.readFileSync": true,
"fs.renameSync": true,
"path.dirname": true,
"path.existsSync": true,
"path.join": true,
"path.resolve": true,
"url.parse": true,
"url.resolve": true,
"util.inherits": true
},
"globals": {
"__dirname": true,
"console.log": true,
"process.arch": true,
"process.cwd": true,
"process.env": true,
"process.platform": true,
"process.version.substr": true,
"process.versions": true
},
"packages": {
"detect-libc": true,
"nopt": true,
"npmlog": true,
"rimraf": true,
"semver": true
}
},
"nopt": {
"builtin": {
"path": true,
"stream.Stream": true,
"url": true
},
"globals": {
"console": true,
"process.argv": true,
"process.env.DEBUG_NOPT": true,
"process.env.NOPT_DEBUG": true,
"process.platform": true
},
"packages": {
"abbrev": true,
"osenv": true
}
},
"normalize-package-data": {
"builtin": {
"url.parse": true,
@ -3535,6 +3660,22 @@
"once": true
}
},
"npmlog": {
"builtin": {
"events.EventEmitter": true,
"util": true
},
"globals": {
"process.nextTick": true,
"process.stderr": true
},
"packages": {
"are-we-there-yet": true,
"console-control-strings": true,
"gauge": true,
"set-blocking": true
}
},
"object-copy": {
"packages": {
"copy-descriptor": true,
@ -3616,6 +3757,54 @@
"readable-stream": true
}
},
"os-homedir": {
"builtin": {
"os.homedir": true
},
"globals": {
"process.env": true,
"process.getuid": true,
"process.platform": true
}
},
"os-tmpdir": {
"globals": {
"process.env.SystemRoot": true,
"process.env.TEMP": true,
"process.env.TMP": true,
"process.env.TMPDIR": true,
"process.env.windir": true,
"process.platform": true
}
},
"osenv": {
"builtin": {
"child_process.exec": true,
"path": true
},
"globals": {
"process.env.COMPUTERNAME": true,
"process.env.ComSpec": true,
"process.env.EDITOR": true,
"process.env.HOSTNAME": true,
"process.env.PATH": true,
"process.env.PROMPT": true,
"process.env.PS1": true,
"process.env.Path": true,
"process.env.SHELL": true,
"process.env.USER": true,
"process.env.USERDOMAIN": true,
"process.env.USERNAME": true,
"process.env.VISUAL": true,
"process.env.path": true,
"process.nextTick": true,
"process.platform": true
},
"packages": {
"os-homedir": true,
"os-tmpdir": true
}
},
"p-limit": {
"packages": {
"p-try": true
@ -4325,6 +4514,12 @@
"lru-cache": true
}
},
"set-blocking": {
"globals": {
"process.stderr": true,
"process.stdout": true
}
},
"set-value": {
"packages": {
"extend-shallow": true,
@ -4588,6 +4783,7 @@
},
"string-width": {
"packages": {
"code-point-at": true,
"emoji-regex": true,
"is-fullwidth-code-point": true,
"strip-ansi": true
@ -5240,6 +5436,11 @@
"isexe": true
}
},
"wide-align": {
"packages": {
"string-width": true
}
},
"write": {
"builtin": {
"fs.createWriteStream": true,

View File

@ -107,7 +107,7 @@
"@keystonehq/metamask-airgapped-keyring": "0.2.1",
"@material-ui/core": "^4.11.0",
"@metamask/contract-metadata": "^1.31.0",
"@metamask/controllers": "^23.0.0",
"@metamask/controllers": "^24.0.0",
"@metamask/eth-ledger-bridge-keyring": "^0.10.0",
"@metamask/eth-token-tracker": "^3.0.1",
"@metamask/etherscan-link": "^2.1.0",
@ -166,6 +166,7 @@
"fast-safe-stringify": "^2.0.7",
"fuse.js": "^3.2.0",
"globalthis": "^1.0.1",
"human-standard-collectible-abi": "^1.0.2",
"human-standard-token-abi": "^2.0.0",
"immer": "^9.0.6",
"json-rpc-engine": "^6.1.0",

View File

@ -1,8 +1,9 @@
import React from 'react';
import React, { useEffect } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { getTokenTrackerLink } from '@metamask/etherscan-link';
import { isEqual } from 'lodash';
import Box from '../../ui/box';
import Card from '../../ui/card';
import Typography from '../../ui/typography/typography';
@ -14,6 +15,7 @@ import {
FLEX_DIRECTION,
OVERFLOW_WRAP,
DISPLAY,
BLOCK_SIZES,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
@ -29,8 +31,11 @@ import {
} from '../../../selectors';
import AssetNavigation from '../../../pages/asset/components/asset-navigation';
import { getCollectibleContracts } from '../../../ducks/metamask/metamask';
import { DEFAULT_ROUTE } from '../../../helpers/constants/routes';
import { removeAndIgnoreCollectible } from '../../../store/actions';
import { DEFAULT_ROUTE, SEND_ROUTE } from '../../../helpers/constants/routes';
import {
checkAndUpdateSingleCollectibleOwnershipStatus,
removeAndIgnoreCollectible,
} from '../../../store/actions';
import {
GOERLI_CHAIN_ID,
KOVAN_CHAIN_ID,
@ -42,11 +47,25 @@ import {
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
import CollectibleOptions from '../collectible-options/collectible-options';
import Button from '../../ui/button';
import { ASSET_TYPES, updateSendAsset } from '../../../ducks/send';
import InfoTooltip from '../../ui/info-tooltip';
import { ERC721 } from '../../../helpers/constants/common';
import { usePrevious } from '../../../hooks/usePrevious';
export default function CollectibleDetails({ collectible }) {
const { image, name, description, address, tokenId } = collectible;
const {
image,
name,
description,
address,
tokenId,
standard,
isCurrentlyOwned,
} = collectible;
const t = useI18nContext();
const history = useHistory();
const dispatch = useDispatch();
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const ipfsGateway = useSelector(getIpfsGateway);
const collectibleContracts = useSelector(getCollectibleContracts);
@ -60,13 +79,19 @@ export default function CollectibleDetails({ collectible }) {
(state) => getSelectedIdentity(state).name,
);
const collectibleImageURL = getAssetImageURL(image, ipfsGateway);
const dispatch = useDispatch();
const onRemove = () => {
dispatch(removeAndIgnoreCollectible(address, tokenId));
history.push(DEFAULT_ROUTE);
};
const prevCollectible = usePrevious(collectible);
useEffect(() => {
if (!isEqual(prevCollectible, collectible)) {
checkAndUpdateSingleCollectibleOwnershipStatus(collectible);
}
}, [collectible, prevCollectible]);
const getOpenSeaLink = () => {
switch (currentNetwork) {
case MAINNET_CHAIN_ID:
@ -84,6 +109,43 @@ export default function CollectibleDetails({ collectible }) {
};
const openSeaLink = getOpenSeaLink();
const sendDisabled = standard !== ERC721;
const inPopUp = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP;
const onSend = async () => {
await dispatch(
updateSendAsset({
type: ASSET_TYPES.COLLECTIBLE,
details: collectible,
}),
);
history.push(SEND_ROUTE);
};
const renderSendButton = () => {
if (isCurrentlyOwned === false) {
return <div style={{ height: '30px' }} />;
}
return (
<Box
display={DISPLAY.FLEX}
width={inPopUp ? BLOCK_SIZES.FULL : BLOCK_SIZES.HALF}
>
<Button
type="primary"
onClick={onSend}
disabled={sendDisabled}
className="collectible-details__send-button"
>
{t('send')}
</Button>
{sendDisabled ? (
<InfoTooltip position="top" contentText={t('sendingDisabled')} />
) : null}
</Box>
);
};
return (
<>
<AssetNavigation
@ -115,43 +177,49 @@ export default function CollectibleDetails({ collectible }) {
</Card>
<Box
flexDirection={FLEX_DIRECTION.COLUMN}
className="collectible-details__top-section__info"
className="collectible-details__info"
justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN}
>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H4}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ margin: 0, marginBottom: 4 }}
>
{name}
</Typography>
<Typography
color={COLORS.UI3}
variant={TYPOGRAPHY.H5}
boxProps={{ margin: 0, marginBottom: 4 }}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
>
{`#${tokenId}`}
</Typography>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
className="collectible-details__description"
boxProps={{ margin: 0, marginBottom: 2 }}
>
{t('description')}
</Typography>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
boxProps={{ margin: 0 }}
>
{description}
</Typography>
<div>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H4}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ margin: 0, marginBottom: 4 }}
>
{name}
</Typography>
<Typography
color={COLORS.UI3}
variant={TYPOGRAPHY.H5}
boxProps={{ margin: 0 }}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
>
#{tokenId}
</Typography>
</div>
<div>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
className="collectible-details__description"
boxProps={{ margin: 0, marginBottom: 2 }}
>
{t('description')}
</Typography>
<Typography
color={COLORS.UI4}
variant={TYPOGRAPHY.H6}
boxProps={{ margin: 0, marginBottom: 4 }}
>
{description}
</Typography>
</div>
{inPopUp ? null : renderSendButton()}
</Box>
</div>
<Box>
<Box marginBottom={2}>
<Box display={DISPLAY.FLEX} flexDirection={FLEX_DIRECTION.ROW}>
<Typography
color={COLORS.BLACK}
@ -226,6 +294,7 @@ export default function CollectibleDetails({ collectible }) {
</a>
</Typography>
</Box>
{inPopUp ? renderSendButton() : null}
</Box>
</Box>
</>
@ -236,6 +305,7 @@ CollectibleDetails.propTypes = {
collectible: PropTypes.shape({
address: PropTypes.string.isRequired,
tokenId: PropTypes.string.isRequired,
isCurrentlyOwned: PropTypes.bool,
name: PropTypes.string,
description: PropTypes.string,
image: PropTypes.string,

View File

@ -19,12 +19,15 @@ $spacer-break-small: 16px;
margin-bottom: $spacer-break-large;
flex-direction: row;
}
}
&__info {
@media screen and (min-width: $break-large) {
max-width: calc(100% - #{$card-width-break-large} - #{$spacer-break-large});
flex: 0 0 calc(100% - #{$card-width-break-large} - #{$spacer-break-large});
}
&__info {
@media screen and (min-width: $break-large) {
max-width:
calc(
100% - #{$card-width-break-large} - #{$spacer-break-large}
);
flex: 0 0 calc(100% - #{$card-width-break-large} - #{$spacer-break-large});
}
}
@ -66,4 +69,12 @@ $spacer-break-small: 16px;
flex: 0 0 $link-title-width;
max-width: 0 0 $link-title-width;
}
&__send-button {
margin-inline-end: 8px;
@media screen and (min-width: $break-large) {
max-width: 160px;
}
}
}

View File

@ -31,13 +31,20 @@ export default function CollectiblesItems({
collections = {},
previouslyOwnedCollection = {},
}) {
const defaultDropdownState = { [PREVIOUSLY_OWNED_KEY]: false };
const [dropdownState, setDropdownState] = useState(defaultDropdownState);
const collectionsKeys = Object.keys(collections);
// if there is only one collection present set it to open when component mounts
const [dropdownState, setDropdownState] = useState(() => {
return collectionsKeys.length === 1
? {
[PREVIOUSLY_OWNED_KEY]: false,
[collectionsKeys[0]]: true,
}
: { [PREVIOUSLY_OWNED_KEY]: false };
});
const ipfsGateway = useSelector(getIpfsGateway);
Object.keys(collections).forEach((key) => {
defaultDropdownState[key] = true;
});
const history = useHistory();
const renderCollectionImage = (
@ -152,7 +159,7 @@ export default function CollectiblesItems({
<div className="collectibles-items">
<Box padding={[6, 4]} flexDirection={FLEX_DIRECTION.COLUMN}>
<>
{Object.keys(collections).map((key) => {
{collectionsKeys.map((key) => {
const {
collectibles,
collectionName,

View File

@ -1,7 +1,8 @@
import React from 'react';
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { isEqual } from 'lodash';
import Box from '../../ui/box';
import Button from '../../ui/button';
import Typography from '../../ui/typography/typography';
@ -25,9 +26,10 @@ import {
import { getIsMainnet, getUseCollectibleDetection } from '../../../selectors';
import { EXPERIMENTAL_ROUTE } from '../../../helpers/constants/routes';
import {
checkAndUpdateCollectiblesOwnershipStatus,
checkAndUpdateAllCollectiblesOwnershipStatus,
detectCollectibles,
} from '../../../store/actions';
import { usePrevious } from '../../../hooks/usePrevious';
export default function CollectiblesTab({ onAddNFT }) {
const collectibles = useSelector(getCollectibles);
@ -40,34 +42,46 @@ export default function CollectiblesTab({ onAddNFT }) {
const history = useHistory();
const t = useI18nContext();
const dispatch = useDispatch();
const [collections, setCollections] = useState({});
const [previouslyOwnedCollection, setPreviouslyOwnedCollection] = useState({
collectionName: 'Previously Owned',
collectibles: [],
});
const getCollections = () => {
const collections = {};
const previouslyOwnedCollection = {
collectionName: 'Previously Owned',
collectibles: [],
const prevCollectibles = usePrevious(collectibles);
useEffect(() => {
const getCollections = () => {
const newCollections = {};
const newPreviouslyOwnedCollections = {
collectionName: 'Previously Owned',
collectibles: [],
};
collectibles.forEach((collectible) => {
if (collectible?.isCurrentlyOwned === false) {
newPreviouslyOwnedCollections.collectibles.push(collectible);
} else if (newCollections[collectible.address]) {
newCollections[collectible.address].collectibles.push(collectible);
} else {
const collectionContract = collectibleContracts.find(
({ address }) => address === collectible.address,
);
newCollections[collectible.address] = {
collectionName: collectionContract?.name || collectible.name,
collectionImage:
collectionContract?.logo || collectible.collectionImage,
collectibles: [collectible],
};
}
});
setCollections(newCollections);
setPreviouslyOwnedCollection(newPreviouslyOwnedCollections);
};
collectibles.forEach((collectible) => {
if (collectible?.isCurrentlyOwned === false) {
previouslyOwnedCollection.collectibles.push(collectible);
} else if (collections[collectible.address]) {
collections[collectible.address].collectibles.push(collectible);
} else {
const collectionContract = collectibleContracts.find(
({ address }) => address === collectible.address,
);
collections[collectible.address] = {
collectionName: collectionContract?.name || collectible.name,
collectionImage:
collectionContract?.logo || collectible.collectionImage,
collectibles: [collectible],
};
}
});
return [collections, previouslyOwnedCollection];
};
const [collections, previouslyOwnedCollection] = getCollections();
if (!isEqual(prevCollectibles, collectibles)) {
getCollections();
}
}, [collectibles, prevCollectibles, collectibleContracts]);
const onEnableAutoDetect = () => {
history.push(EXPERIMENTAL_ROUTE);
@ -77,12 +91,13 @@ export default function CollectiblesTab({ onAddNFT }) {
if (isMainnet) {
dispatch(detectCollectibles());
}
checkAndUpdateCollectiblesOwnershipStatus();
checkAndUpdateAllCollectiblesOwnershipStatus();
};
return (
<div className="collectibles-tab">
{collectibles.length > 0 ? (
{Object.keys(collections).length > 0 ||
previouslyOwnedCollection.collectibles.length > 0 ? (
<CollectiblesItems
collections={collections}
previouslyOwnedCollection={previouslyOwnedCollection}

View File

@ -173,12 +173,12 @@ describe('Collectible Items', () => {
const detectCollectiblesStub = jest.fn();
const setCollectiblesDetectionNoticeDismissedStub = jest.fn();
const getStateStub = jest.fn();
const checkAndUpdateCollectiblesOwnershipStatusStub = jest.fn();
const checkAndUpdateAllCollectiblesOwnershipStatusStub = jest.fn();
setBackgroundConnection({
setCollectiblesDetectionNoticeDismissed: setCollectiblesDetectionNoticeDismissedStub,
detectCollectibles: detectCollectiblesStub,
getState: getStateStub,
checkAndUpdateCollectiblesOwnershipStatus: checkAndUpdateCollectiblesOwnershipStatusStub,
checkAndUpdateAllCollectiblesOwnershipStatus: checkAndUpdateAllCollectiblesOwnershipStatusStub,
});
const historyPushMock = jest.fn();
@ -276,11 +276,13 @@ describe('Collectible Items', () => {
});
expect(detectCollectiblesStub).not.toHaveBeenCalled();
expect(
checkAndUpdateCollectiblesOwnershipStatusStub,
checkAndUpdateAllCollectiblesOwnershipStatusStub,
).not.toHaveBeenCalled();
fireEvent.click(screen.queryByText('Refresh list'));
expect(detectCollectiblesStub).toHaveBeenCalled();
expect(checkAndUpdateCollectiblesOwnershipStatusStub).toHaveBeenCalled();
expect(
checkAndUpdateAllCollectiblesOwnershipStatusStub,
).toHaveBeenCalled();
});
it('should render a link "Refresh list" when some collectibles are present on a non-mainnet chain, which, when clicked calls a method checkAndUpdateCollectiblesOwnershipStatus', () => {
@ -291,10 +293,12 @@ describe('Collectible Items', () => {
useCollectibleDetection: true,
});
expect(
checkAndUpdateCollectiblesOwnershipStatusStub,
checkAndUpdateAllCollectiblesOwnershipStatusStub,
).not.toHaveBeenCalled();
fireEvent.click(screen.queryByText('Refresh list'));
expect(checkAndUpdateCollectiblesOwnershipStatusStub).toHaveBeenCalled();
expect(
checkAndUpdateAllCollectiblesOwnershipStatusStub,
).toHaveBeenCalled();
});
it('should render a link "Enable Autodetect" when some collectibles are present and collectible auto-detection preference is set to false, which, when clicked sends user to the experimental tab of settings', () => {

View File

@ -25,6 +25,7 @@ export default class ConfirmPageContainerContent extends Component {
nonce: PropTypes.string,
subtitleComponent: PropTypes.node,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
image: PropTypes.string,
titleComponent: PropTypes.node,
warning: PropTypes.string,
origin: PropTypes.string.isRequired,
@ -87,6 +88,7 @@ export default class ConfirmPageContainerContent extends Component {
errorMessage,
hasSimulationError,
title,
image,
titleComponent,
subtitleComponent,
hideSubtitle,
@ -140,6 +142,7 @@ export default class ConfirmPageContainerContent extends Component {
})}
action={action}
title={title}
image={image}
titleComponent={titleComponent}
subtitleComponent={subtitleComponent}
hideSubtitle={hideSubtitle}

View File

@ -3,7 +3,6 @@ import React from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Identicon from '../../../../ui/identicon';
import { useGasFeeContext } from '../../../../../contexts/gasFee';
const ConfirmPageContainerSummary = (props) => {
const {
@ -17,9 +16,30 @@ const ConfirmPageContainerSummary = (props) => {
nonce,
origin,
hideTitle,
image,
} = props;
const { supportsEIP1559V2 } = useGasFeeContext();
const renderImage = () => {
if (image) {
return (
<img
className="confirm-page-container-summary__icon"
width={36}
src={image}
/>
);
} else if (identiconAddress) {
return (
<Identicon
className="confirm-page-container-summary__icon"
diameter={36}
address={identiconAddress}
image={image}
/>
);
}
return null;
};
return (
<div className={classnames('confirm-page-container-summary', className)}>
@ -34,25 +54,21 @@ const ConfirmPageContainerSummary = (props) => {
</div>
)}
</div>
<div className="confirm-page-container-summary__title">
{identiconAddress && (
<Identicon
className="confirm-page-container-summary__identicon"
diameter={36}
address={identiconAddress}
/>
)}
{!hideTitle ? (
<div className="confirm-page-container-summary__title-text">
{titleComponent || title}
</div>
) : null}
</div>
{!hideSubtitle && !supportsEIP1559V2 && (
<div className="confirm-page-container-summary__subtitle">
{subtitleComponent}
<>
<div className="confirm-page-container-summary__title">
{renderImage()}
{!hideTitle ? (
<div className="confirm-page-container-summary__title-text">
{titleComponent || title}
</div>
) : null}
</div>
)}
{hideSubtitle ? null : (
<div className="confirm-page-container-summary__subtitle">
{subtitleComponent}
</div>
)}
</>
</div>
);
};
@ -60,6 +76,7 @@ const ConfirmPageContainerSummary = (props) => {
ConfirmPageContainerSummary.propTypes = {
action: PropTypes.string,
title: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
image: PropTypes.string,
titleComponent: PropTypes.node,
subtitleComponent: PropTypes.node,
hideSubtitle: PropTypes.bool,

View File

@ -1,8 +1,11 @@
.confirm-page-container-summary {
padding: 16px 24px 0;
padding: 0 24px;
background-color: #f9fafa;
height: 133px;
height: 120px;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: space-evenly;
&__origin {
@include H6;
@ -36,13 +39,13 @@
align-items: center;
}
&__identicon {
&__icon {
flex: 0 0 auto;
margin-right: 8px;
}
&__title-text {
@include H1;
@include H2;
white-space: nowrap;
overflow: hidden;
@ -50,10 +53,13 @@
}
&__subtitle {
@include H6;
color: var(--oslo-gray);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-left: 42px;
}
&--border {

View File

@ -39,6 +39,7 @@ export default class ConfirmPageContainer extends Component {
showEdit: PropTypes.bool,
subtitleComponent: PropTypes.node,
title: PropTypes.string,
image: PropTypes.string,
titleComponent: PropTypes.node,
hideSenderToRecipient: PropTypes.bool,
showAccountInHeader: PropTypes.bool,
@ -103,6 +104,7 @@ export default class ConfirmPageContainer extends Component {
contentComponent,
action,
title,
image,
titleComponent,
subtitleComponent,
hideSubtitle,
@ -206,6 +208,7 @@ export default class ConfirmPageContainer extends Component {
<ConfirmPageContainerContent
action={action}
title={title}
image={image}
titleComponent={titleComponent}
subtitleComponent={subtitleComponent}
hideSubtitle={hideSubtitle}

View File

@ -10,6 +10,7 @@ import {
DISPLAY,
FLEX_WRAP,
ALIGN_ITEMS,
TEXT_ALIGN,
} from '../../../helpers/constants/design-system';
export default function TransactionDetailItem({
@ -52,6 +53,7 @@ export default function TransactionDetailItem({
fontWeight={boldHeadings ? FONT_WEIGHT.BOLD : FONT_WEIGHT.NORMAL}
variant={TYPOGRAPHY.H6}
margin={[1, 0, 1, 1]}
boxProps={{ textAlign: TEXT_ALIGN.RIGHT }}
>
{detailTotal}
</Typography>

View File

@ -2,6 +2,7 @@ import React, { PureComponent } from 'react';
import PropTypes from 'prop-types';
import classnames from 'classnames';
import Jazzicon from '../jazzicon';
import { getAssetImageURL } from '../../../helpers/utils/util';
import BlockieIdenticon from './blockieIdenticon';
const getStyles = (diameter) => ({
@ -54,6 +55,10 @@ export default class Identicon extends PureComponent {
* Add list of token in object
*/
tokenList: PropTypes.object,
/**
* User preferred IPFS gateway
*/
ipfsGateway: PropTypes.string,
};
static defaultProps = {
@ -68,7 +73,12 @@ export default class Identicon extends PureComponent {
};
renderImage() {
const { className, diameter, image, alt, imageBorder } = this.props;
const { className, diameter, alt, imageBorder, ipfsGateway } = this.props;
let { image } = this.props;
if (image.toLowerCase().startsWith('ipfs://')) {
image = getAssetImageURL(image, ipfsGateway);
}
return (
<img

View File

@ -3,13 +3,14 @@ import Identicon from './identicon.component';
const mapStateToProps = (state) => {
const {
metamask: { useBlockie, useTokenDetection, tokenList },
metamask: { useBlockie, useTokenDetection, tokenList, ipfsGateway },
} = state;
return {
useBlockie,
useTokenDetection,
tokenList,
ipfsGateway,
};
};

View File

@ -13,7 +13,10 @@ import {
addEth,
} from '../../helpers/utils/confirm-tx.util';
import { getTokenData, sumHexes } from '../../helpers/utils/transactions.util';
import {
getTransactionData,
sumHexes,
} from '../../helpers/utils/transactions.util';
import { conversionUtil } from '../../../shared/modules/conversion.utils';
import { getAveragePriceEstimateInHexWEI } from '../../selectors/custom-gas';
@ -282,7 +285,7 @@ export function setTransactionToConfirm(transactionId) {
if (txParams.data) {
const { to: tokenAddress, data } = txParams;
const tokenData = getTokenData(data);
const tokenData = getTransactionData(data);
const tokens = getTokens(state);
const currentToken = tokens?.find(({ address }) =>
isEqualCaseInsensitive(tokenAddress, address),

View File

@ -1,5 +1,6 @@
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
import abi from 'human-standard-token-abi';
import abiERC721 from 'human-standard-collectible-abi';
import BigNumber from 'bignumber.js';
import { addHexPrefix } from 'ethereumjs-util';
import { debounce } from 'lodash';
@ -24,7 +25,9 @@ import {
import {
addGasBuffer,
calcGasTotal,
generateTokenTransferData,
generateERC20TransferData,
generateERC721TransferData,
getAssetTransferData,
isBalanceSufficient,
isTokenBalanceSufficient,
} from '../../pages/send/send.utils';
@ -54,6 +57,7 @@ import {
updateTransaction,
addPollingTokenToAppState,
removePollingTokenFromAppState,
isCollectibleOwner,
} from '../../store/actions';
import { setCustomGasLimit } from '../gas/gas.duck';
import {
@ -87,7 +91,7 @@ import {
isValidHexAddress,
} from '../../../shared/modules/hexstring-utils';
import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network';
import { ETH, GWEI } from '../../helpers/constants/common';
import { ERC20, ETH, GWEI } from '../../helpers/constants/common';
import { TRANSACTION_ENVELOPE_TYPES } from '../../../shared/constants/transaction';
import { readAddressAsContract } from '../../../shared/modules/contract-utils';
// typedefs
@ -155,10 +159,12 @@ export const GAS_INPUT_MODES = {
* The types of assets that a user can send
* 1. NATIVE - The native asset for the current network, such as ETH
* 2. TOKEN - An ERC20 token.
* 2. COLLECTIBLE - An ERC721 or ERC1155 token.
*/
export const ASSET_TYPES = {
NATIVE: 'NATIVE',
TOKEN: 'TOKEN',
COLLECTIBLE: 'COLLECTIBLE',
};
/**
@ -218,13 +224,16 @@ async function estimateGasLimitForSend({
return GAS_LIMITS.BASE_TOKEN_ESTIMATE;
}
paramsForGasEstimate.value = '0x0';
// We have to generate the erc20 contract call to transfer tokens in
// We have to generate the erc20/erc721 contract call to transfer tokens in
// order to get a proper estimate for gasLimit.
paramsForGasEstimate.data = generateTokenTransferData({
paramsForGasEstimate.data = getAssetTransferData({
sendToken,
fromAddress: selectedAddress,
toAddress: to,
amount: value,
sendToken,
});
paramsForGasEstimate.to = sendToken.address;
} else {
if (!data) {
@ -469,7 +478,7 @@ export const initializeSendState = createAsyncThunk(
// Set a basic gasLimit in the event that other estimation fails
let gasLimit =
asset.type === ASSET_TYPES.TOKEN
asset.type === ASSET_TYPES.TOKEN || asset.type === ASSET_TYPES.COLLECTIBLE
? GAS_LIMITS.BASE_TOKEN_ESTIMATE
: GAS_LIMITS.SIMPLE;
if (
@ -510,6 +519,17 @@ export const initializeSendState = createAsyncThunk(
}
balance = await getERC20Balance(asset.details, fromAddress);
}
if (asset.type === ASSET_TYPES.COLLECTIBLE) {
if (asset.details === null) {
// If we're sending a collectible but details have not been provided we must
// abort and set the send slice into invalid status.
throw new Error(
'Send slice initialized as collectibles send without token details',
);
}
balance = '0x1';
}
return {
address: fromAddress,
nativeBalance: account.balance,
@ -867,7 +887,10 @@ const slice = createSlice({
updateAsset: (state, action) => {
state.asset.type = action.payload.type;
state.asset.balance = action.payload.balance;
if (state.asset.type === ASSET_TYPES.TOKEN) {
if (
state.asset.type === ASSET_TYPES.TOKEN ||
state.asset.type === ASSET_TYPES.COLLECTIBLE
) {
state.asset.details = action.payload.details;
} else {
// clear the details object when sending native currency
@ -939,12 +962,25 @@ const slice = createSlice({
// amount.
state.draftTransaction.txParams.to = state.asset.details.address;
state.draftTransaction.txParams.value = '0x0';
state.draftTransaction.txParams.data = generateTokenTransferData({
state.draftTransaction.txParams.data = generateERC20TransferData({
toAddress: state.recipient.address,
amount: state.amount.value,
sendToken: state.asset.details,
});
break;
case ASSET_TYPES.COLLECTIBLE:
// When sending a token the to address is the contract address of
// the token being sent. The value is set to '0x0' and the data
// is generated from the recipient address, token being sent and
// amount.
state.draftTransaction.txParams.to = state.asset.details.address;
state.draftTransaction.txParams.value = '0x0';
state.draftTransaction.txParams.data = generateERC721TransferData({
toAddress: state.recipient.address,
fromAddress: state.account.address,
tokenId: state.asset.details.tokenId,
});
break;
case ASSET_TYPES.NATIVE:
default:
// When sending native currency the to and value fields use the
@ -1018,7 +1054,9 @@ const slice = createSlice({
recipient.error = null;
recipient.warning = null;
} else {
const isSendingToken = asset.type === ASSET_TYPES.TOKEN;
const isSendingToken =
asset.type === ASSET_TYPES.TOKEN ||
asset.type === ASSET_TYPES.COLLECTIBLE;
const { chainId, tokens, tokenAddressList } = action.payload;
if (
isBurnAddress(recipient.userInput) ||
@ -1395,12 +1433,37 @@ export function updateSendAsset({ type, details }) {
details,
state.send.account.address ?? getSelectedAddress(state),
);
// TODO remove along with migration of isERC721 tokens and stripping away this designation
if (details && details.isERC721 === undefined) {
const updatedAssetDetails = await updateTokenType(details.address);
details.isERC721 = updatedAssetDetails.isERC721;
}
details.standard = ERC20;
await dispatch(hideLoadingIndication());
} else if (type === ASSET_TYPES.COLLECTIBLE) {
let isCurrentOwner = true;
try {
isCurrentOwner = await isCollectibleOwner(
getSelectedAddress(state),
details.address,
details.tokenId,
);
} catch (error) {
if (error.message.includes('Unable to verify ownership.')) {
// 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.
} else {
// Any other error is unexpected and should be surfaced.
dispatch(displayWarning(error.message));
}
}
if (isCurrentOwner) {
balance = '0x1';
} else {
throw new Error(
'Send slice initialized as collectible send with a collectible not currently owned by the select account',
);
}
} else {
// if changing to native currency, get it from the account key in send
// state which is kept in sync when accounts change.
@ -1559,6 +1622,7 @@ export function signTransaction() {
draftTransaction: { id, txParams },
recipient: { address },
amount: { value },
account: { address: selectedAddress },
eip1559support,
} = state[name];
if (stage === SEND_STAGES.EDIT) {
@ -1598,6 +1662,34 @@ export function signTransaction() {
to: undefined,
data: undefined,
});
dispatch(showConfTxPage());
dispatch(hideLoadingIndication());
} catch (error) {
dispatch(hideLoadingIndication());
dispatch(displayWarning(error.message));
}
} else if (asset.type === ASSET_TYPES.COLLECTIBLE) {
// When sending a collectible transaction we have to use the collectible.transferFrom method
// on the collectible contract to construct the transaction. This results in
// the proper transaction data and properties being set and a new
// transaction being added to background state. Once the new transaction
// is added to state a subsequent confirmation will be queued.
try {
const collectibleContract = global.eth
.contract(abiERC721)
.at(asset.details.address);
collectibleContract.transferFrom(
selectedAddress,
address,
asset.details.tokenId,
{
...txParams,
to: undefined,
data: undefined,
},
);
dispatch(showConfTxPage());
dispatch(hideLoadingIndication());
} catch (error) {
@ -1655,7 +1747,7 @@ export function editTransaction(
throw new Error(
`send/editTransaction dispatched with assetType 'TOKEN' but missing assetData or assetDetails parameter`,
);
} else {
} else if (assetType === ASSET_TYPES.TOKEN) {
const {
data,
from,
@ -1693,6 +1785,36 @@ export function editTransaction(
nickname,
}),
);
} else if (assetType === ASSET_TYPES.COLLECTIBLE) {
const {
data,
from,
to: tokenAddress,
gas: gasLimit,
gasPrice,
} = txParams;
const address = getTokenAddressParam(tokenData);
const nickname = getAddressBookEntry(state, address)?.name ?? '';
await dispatch(
updateSendAsset({
type: ASSET_TYPES.COLLECTIBLE,
details: { ...assetDetails, address: tokenAddress },
}),
);
await dispatch(
actions.editTransaction({
data,
id: transactionId,
gasLimit,
gasPrice,
from,
amount: '0x1',
address,
nickname,
}),
);
}
};
}

View File

@ -74,6 +74,7 @@ jest.mock('../../store/actions', () => {
estimateGas: jest.fn(() => Promise.resolve('0x0')),
getGasFeeEstimatesAndStartPolling: jest.fn(() => Promise.resolve()),
updateTokenType: jest.fn(() => Promise.resolve({ isERC721: false })),
isCollectibleOwner: jest.fn(() => Promise.resolve(true)),
};
});
@ -2004,6 +2005,7 @@ describe('Send Slice', () => {
draftTransaction: {},
recipient: {},
amount: {},
account: {},
},
};
@ -2139,7 +2141,7 @@ describe('Send Slice', () => {
);
});
it('should set up the appropriate state for editing a token asset transaction', async () => {
it('should set up the appropriate state for editing a collectible asset transaction', async () => {
const editTransactionState = {
metamask: {
blockGasLimit: '0x3a98',
@ -2159,7 +2161,7 @@ describe('Send Slice', () => {
data: '',
from: '0xAddress',
to: '0xTokenAddress',
gas: GAS_LIMITS.SIMPLE,
gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE,
gasPrice: '0x3b9aca00', // 1000000000
value: '0x0',
},
@ -2203,56 +2205,54 @@ describe('Send Slice', () => {
await store.dispatch(
editTransaction(
ASSET_TYPES.TOKEN,
ASSET_TYPES.COLLECTIBLE,
1,
{
name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER,
name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM,
args: {
_to: '0xRecipientAddress',
_value: ethers.BigNumber.from(15000),
},
},
{ address: '0xAddress', symbol: 'SYMB', decimals: 18 },
{
address: '0xf5de760f2e916647fd766B4AD9E85ff943cE3A2b',
description: 'A test NFT dispensed from faucet.paradigm.xyz.',
image:
'https://ipfs.io/ipfs/bafybeifvwitulq6elvka2hoqhwixfhgb42l4aiukmtrw335osetikviuuu',
name: 'MultiFaucet Test NFT',
standard: 'ERC721',
tokenId: '26847',
},
),
);
const actionResult = store.getActions();
expect(actionResult).toHaveLength(7);
expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION');
expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION');
expect(actionResult[2].type).toStrictEqual('send/updateAsset');
expect(actionResult[2].payload).toStrictEqual({
balance: '0x0',
type: ASSET_TYPES.TOKEN,
expect(actionResult).toHaveLength(5);
expect(actionResult[0].type).toStrictEqual('send/updateAsset');
expect(actionResult[0].payload).toStrictEqual({
balance: '0x1',
type: ASSET_TYPES.COLLECTIBLE,
details: {
address: '0xTokenAddress',
decimals: 18,
symbol: 'SYMB',
isERC721: false,
description: 'A test NFT dispensed from faucet.paradigm.xyz.',
image:
'https://ipfs.io/ipfs/bafybeifvwitulq6elvka2hoqhwixfhgb42l4aiukmtrw335osetikviuuu',
name: 'MultiFaucet Test NFT',
standard: 'ERC721',
tokenId: '26847',
},
});
expect(actionResult[3].type).toStrictEqual(
expect(actionResult[1].type).toStrictEqual(
'send/computeEstimatedGasLimit/pending',
);
expect(actionResult[4].type).toStrictEqual(
expect(actionResult[2].type).toStrictEqual(
'metamask/gas/SET_CUSTOM_GAS_LIMIT',
);
expect(actionResult[5].type).toStrictEqual(
expect(actionResult[3].type).toStrictEqual(
'send/computeEstimatedGasLimit/fulfilled',
);
expect(actionResult[6].type).toStrictEqual('send/editTransaction');
expect(actionResult[6].payload).toStrictEqual({
address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase
amount: '0x3a98',
data: '',
from: '0xAddress',
gasLimit: GAS_LIMITS.SIMPLE,
gasPrice: '0x3b9aca00',
id: 1,
nickname: '',
});
expect(actionResult[4].type).toStrictEqual('send/editTransaction');
const action = actionResult[6];
const action = actionResult[4];
const result = sendReducer(initialState, action);
@ -2275,6 +2275,143 @@ describe('Send Slice', () => {
);
});
});
it('should set up the appropriate state for editing a token asset transaction', async () => {
const editTransactionState = {
metamask: {
blockGasLimit: '0x3a98',
selectedAddress: '',
provider: {
chainId: RINKEBY_CHAIN_ID,
},
tokens: [],
addressBook: {
[RINKEBY_CHAIN_ID]: {},
},
identities: {},
unapprovedTxs: {
1: {
id: 1,
txParams: {
data: '',
from: '0xAddress',
to: '0xTokenAddress',
gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE,
gasPrice: '0x3b9aca00', // 1000000000
value: '0x0',
},
},
},
},
send: {
account: {
address: '0xAddress',
balance: '0x0',
},
asset: {
type: '',
},
gas: {
gasPrice: '',
},
amount: {
value: '',
},
draftTransaction: {
userInputHexData: '',
},
recipient: {
address: 'Address',
nickname: 'NickName',
},
},
};
global.eth = {
contract: sinon.stub().returns({
at: sinon.stub().returns({
balanceOf: sinon.stub().returns(undefined),
}),
}),
getCode: jest.fn(() => '0xa'),
};
const store = mockStore(editTransactionState);
await store.dispatch(
editTransaction(
ASSET_TYPES.TOKEN,
1,
{
name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER,
args: {
_to: '0xRecipientAddress',
_value: ethers.BigNumber.from(15000),
},
},
{ address: '0xAddress', symbol: 'SYMB', decimals: 18 },
),
);
const actionResult = store.getActions();
expect(actionResult).toHaveLength(7);
expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION');
expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION');
expect(actionResult[2].type).toStrictEqual('send/updateAsset');
expect(actionResult[2].payload).toStrictEqual({
balance: '0x0',
type: ASSET_TYPES.TOKEN,
details: {
address: '0xTokenAddress',
decimals: 18,
symbol: 'SYMB',
isERC721: false,
standard: 'ERC20',
},
});
expect(actionResult[3].type).toStrictEqual(
'send/computeEstimatedGasLimit/pending',
);
expect(actionResult[4].type).toStrictEqual(
'metamask/gas/SET_CUSTOM_GAS_LIMIT',
);
expect(actionResult[5].type).toStrictEqual(
'send/computeEstimatedGasLimit/fulfilled',
);
expect(actionResult[6].type).toStrictEqual('send/editTransaction');
expect(actionResult[6].payload).toStrictEqual({
address: '0xrecipientaddress', // getting address from tokenData does .toLowerCase
amount: '0x3a98',
data: '',
from: '0xAddress',
gasLimit: GAS_LIMITS.BASE_TOKEN_ESTIMATE,
gasPrice: '0x3b9aca00',
id: 1,
nickname: '',
});
const action = actionResult[6];
const result = sendReducer(initialState, action);
expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit);
expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice);
expect(result.amount.value).toStrictEqual(action.payload.amount);
expect(result.draftTransaction.txParams.to).toStrictEqual(
action.payload.address,
);
expect(result.draftTransaction.txParams.value).toStrictEqual(
action.payload.amount,
);
expect(result.draftTransaction.txParams.gasPrice).toStrictEqual(
action.payload.gasPrice,
);
expect(result.draftTransaction.txParams.gas).toStrictEqual(
action.payload.gasLimit,
);
});
});
describe('selectors', () => {

View File

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

View File

@ -33,7 +33,7 @@ const hstInterface = new ethers.utils.Interface(abi);
* @param data
* @returns {EthersContractCall | undefined}
*/
export function getTokenData(data) {
export function getTransactionData(data) {
try {
return hstInterface.parseTransaction({ data });
} catch (error) {

View File

@ -7,9 +7,9 @@ import {
import * as utils from './transactions.util';
describe('Transactions utils', () => {
describe('getTokenData', () => {
describe('getTransactionData', () => {
it('should return token data', () => {
const tokenData = utils.getTokenData(
const tokenData = utils.getTransactionData(
'0xa9059cbb00000000000000000000000050a9d56c2b8ba9a5c7f2c08c3d26e0499f23a7060000000000000000000000000000000000000000000000000000000000004e20',
);
expect(tokenData).toStrictEqual(expect.anything());
@ -22,7 +22,7 @@ describe('Transactions utils', () => {
});
it('should not throw errors when called without arguments', () => {
expect(() => utils.getTokenData()).not.toThrow();
expect(() => utils.getTransactionData()).not.toThrow();
});
});

View File

@ -1,5 +1,5 @@
import { useMemo } from 'react';
import { getTokenData } from '../helpers/utils/transactions.util';
import { getTransactionData } from '../helpers/utils/transactions.util';
/**
* useTokenData
@ -19,6 +19,6 @@ export function useTokenData(transactionData, isTokenTransaction = true) {
if (!isTokenTransaction || !transactionData) {
return null;
}
return getTokenData(transactionData);
return getTransactionData(transactionData);
}, [isTokenTransaction, transactionData]);
}

View File

@ -122,7 +122,7 @@ describe('useTokenDisplayValue', () => {
describe(`when input is decimals: ${token.decimals} and value: ${tokenValue}`, () => {
it(`should return ${displayValue} as displayValue`, () => {
const getTokenValueStub = sinon.stub(tokenUtil, 'getTokenValueParam');
const getTokenDataStub = sinon.stub(txUtil, 'getTokenData');
const getTokenDataStub = sinon.stub(txUtil, 'getTransactionData');
getTokenDataStub.callsFake(() => tokenData);
getTokenValueStub.callsFake(() => tokenValue);

View File

@ -6,7 +6,10 @@ import {
} from '../helpers/utils/transactions.util';
import { camelCaseToCapitalize } from '../helpers/utils/common.util';
import { PRIMARY, SECONDARY } from '../helpers/constants/common';
import { getTokenAddressParam } from '../helpers/utils/token-util';
import {
getTokenAddressParam,
getTokenValueParam,
} from '../helpers/utils/token-util';
import {
isEqualCaseInsensitive,
formatDateWithYearContext,
@ -18,7 +21,7 @@ import {
PENDING_STATUS_HASH,
TOKEN_CATEGORY_HASH,
} from '../helpers/constants/transactions';
import { getTokens } from '../ducks/metamask/metamask';
import { getCollectibles, getTokens } from '../ducks/metamask/metamask';
import {
TRANSACTION_TYPES,
TRANSACTION_GROUP_CATEGORIES,
@ -64,6 +67,7 @@ export function useTransactionDisplayData(transactionGroup) {
const dispatch = useDispatch();
const currentAsset = useCurrentAsset();
const knownTokens = useSelector(getTokens);
const knownCollectibles = useSelector(getCollectibles);
const t = useI18nContext();
const { initialTransaction, primaryTransaction } = transactionGroup;
// initialTransaction contains the data we need to derive the primary purpose of this transaction group
@ -103,10 +107,24 @@ export function useTransactionDisplayData(transactionGroup) {
knownTokens.find(({ address }) =>
isEqualCaseInsensitive(address, recipientAddress),
);
const tokenData = useTokenData(
initialTransaction?.txParams?.data,
isTokenCategory,
);
// If this is an ERC20 token transaction this value is equal to the amount sent
// If it is an ERC721 token transaction it is the tokenId being sent
const tokenAmountOrTokenId = getTokenValueParam(tokenData);
const collectible =
isTokenCategory &&
knownCollectibles.find(
({ address, tokenId }) =>
isEqualCaseInsensitive(address, recipientAddress) &&
tokenId === tokenAmountOrTokenId,
);
const tokenDisplayValue = useTokenDisplayValue(
initialTransaction?.txParams?.data,
token,
@ -220,7 +238,9 @@ export function useTransactionDisplayData(transactionGroup) {
type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER
) {
category = TRANSACTION_GROUP_CATEGORIES.SEND;
title = t('sendSpecifiedTokens', [token?.symbol || t('token')]);
title = t('sendSpecifiedTokens', [
token?.symbol || collectible?.name || t('token'),
]);
recipientAddress = getTokenAddressParam(tokenData);
subtitle = t('toAddress', [shortenAddress(recipientAddress)]);
} else if (type === TRANSACTION_TYPES.SIMPLE_SEND) {

View File

@ -8,7 +8,7 @@ import {
updateCustomNonce,
getNextNonce,
} from '../../store/actions';
import { getTokenData } from '../../helpers/utils/transactions.util';
import { getTransactionData } from '../../helpers/utils/transactions.util';
import {
calcTokenAmount,
getTokenAddressParam,
@ -105,7 +105,7 @@ export default function ConfirmApprove() {
const tokenSymbol = currentToken?.symbol;
const decimals = Number(currentToken?.decimals);
const tokenImage = currentToken?.image;
const tokenData = getTokenData(data);
const tokenData = getTransactionData(data);
const tokenValue = getTokenValueParam(tokenData);
const toAddress = getTokenAddressParam(tokenData);
const tokenAmount =

View File

@ -4,13 +4,13 @@ import {
calcTokenValue,
getTokenAddressParam,
} from '../../helpers/utils/token-util';
import { getTokenData } from '../../helpers/utils/transactions.util';
import { getTransactionData } from '../../helpers/utils/transactions.util';
export function getCustomTxParamsData(
data,
{ customPermissionAmount, decimals },
) {
const tokenData = getTokenData(data);
const tokenData = getTransactionData(data);
if (!tokenData) {
throw new Error('Invalid data');

View File

@ -14,10 +14,12 @@ import { getWeiHexFromDecimalValue } from '../../helpers/utils/conversions.util'
import { ETH, PRIMARY } from '../../helpers/constants/common';
export default function ConfirmTokenTransactionBase({
image,
title,
subtitle,
toAddress,
tokenAddress,
tokenAmount = '0',
tokenSymbol,
fiatTransactionTotal,
ethTransactionTotal,
ethTransactionTotalMaxAmount,
@ -69,38 +71,45 @@ export default function ConfirmTokenTransactionBase({
tokenAmount,
]);
const tokensText = `${tokenAmount} ${tokenSymbol}`;
const subtitleComponent = () => {
if (contractExchangeRate === undefined && subtitle === undefined) {
return <span>{t('noConversionRateAvailable')}</span>;
}
if (subtitle) {
return <span>{subtitle}</span>;
}
return (
<UserPreferencedCurrencyDisplay
value={hexWeiValue}
type={PRIMARY}
showEthLogo
hideLabel
/>
);
};
return (
<ConfirmTransactionBase
toAddress={toAddress}
image={image}
onEdit={onEdit}
identiconAddress={tokenAddress}
title={tokensText}
subtitleComponent={
contractExchangeRate === undefined ? (
<span>{t('noConversionRateAvailable')}</span>
) : (
<UserPreferencedCurrencyDisplay
value={hexWeiValue}
type={PRIMARY}
showEthLogo
hideLabel
/>
)
}
primaryTotalTextOverride={`${tokensText} + ${ethTransactionTotal} ${nativeCurrency}`}
primaryTotalTextOverrideMaxAmount={`${tokensText} + ${ethTransactionTotalMaxAmount} ${nativeCurrency}`}
title={title}
subtitleComponent={subtitleComponent()}
primaryTotalTextOverride={`${title} + ${ethTransactionTotal} ${nativeCurrency}`}
primaryTotalTextOverrideMaxAmount={`${title} + ${ethTransactionTotalMaxAmount} ${nativeCurrency}`}
secondaryTotalTextOverride={secondaryTotalTextOverride}
/>
);
}
ConfirmTokenTransactionBase.propTypes = {
image: PropTypes.string,
title: PropTypes.string,
subtitle: PropTypes.string,
tokenAddress: PropTypes.string,
toAddress: PropTypes.string,
tokenAmount: PropTypes.string,
tokenSymbol: PropTypes.string,
fiatTransactionTotal: PropTypes.string,
ethTransactionTotal: PropTypes.string,
contractExchangeRate: PropTypes.number,

View File

@ -5,8 +5,8 @@ import {
contractExchangeRateSelector,
transactionFeeSelector,
} from '../../selectors';
import { getTokens } from '../../ducks/metamask/metamask';
import { getTokenData } from '../../helpers/utils/transactions.util';
import { getCollectibles, getTokens } from '../../ducks/metamask/metamask';
import { getTransactionData } from '../../helpers/utils/transactions.util';
import {
calcTokenAmount,
getTokenAddressParam,
@ -49,27 +49,53 @@ const mapStateToProps = (state, ownProps) => {
hexMaximumTransactionFee,
} = transactionFeeSelector(state, transaction);
const tokens = getTokens(state);
const currentToken = tokens?.find(({ address }) =>
isEqualCaseInsensitive(tokenAddress, address),
);
const { decimals, symbol: tokenSymbol } = currentToken || {};
const collectibles = getCollectibles(state);
const transactionData = getTransactionData(data);
const toAddress = getTokenAddressParam(transactionData);
const tokenAmountOrTokenId = getTokenValueParam(transactionData);
const ethTransactionTotalMaxAmount = Number(
hexWEIToDecETH(hexMaximumTransactionFee),
).toFixed(6);
const tokenData = getTokenData(data);
const tokenValue = getTokenValueParam(tokenData);
const toAddress = getTokenAddressParam(tokenData);
const tokenAmount =
tokenData && calcTokenAmount(tokenValue, decimals).toFixed();
const contractExchangeRate = contractExchangeRateSelector(state);
const currentToken = tokens?.find(({ address }) =>
isEqualCaseInsensitive(tokenAddress, address),
);
const currentCollectible = collectibles?.find(
({ address, tokenId }) =>
isEqualCaseInsensitive(tokenAddress, address) &&
tokenId === tokenAmountOrTokenId,
);
let image,
tokenId,
collectibleName,
tokenAmount,
contractExchangeRate,
title,
subtitle;
if (currentCollectible) {
({ image, tokenId, name: collectibleName } = currentCollectible || {});
title = collectibleName;
subtitle = `#${tokenId}`;
} else if (currentToken) {
const { decimals, symbol: tokenSymbol } = currentToken || {};
tokenAmount =
transactionData &&
calcTokenAmount(tokenAmountOrTokenId, decimals).toFixed();
contractExchangeRate = contractExchangeRateSelector(state);
title = `${tokenAmount} ${tokenSymbol}`;
}
return {
title,
subtitle,
image,
toAddress,
tokenAddress,
tokenAmount,
tokenSymbol,
currentCurrency,
conversionRate,
contractExchangeRate,

View File

@ -121,6 +121,7 @@ export default class ConfirmTransactionBase extends Component {
onEdit: PropTypes.func,
subtitleComponent: PropTypes.node,
title: PropTypes.string,
image: PropTypes.string,
type: PropTypes.string,
getNextNonce: PropTypes.func,
nextNonce: PropTypes.number,
@ -613,7 +614,7 @@ export default class ConfirmTransactionBase extends Component {
<LoadingHeartBeat />
<strong key="editGasSubTextAmountLabel">
{t('editGasSubTextAmountLabel')}
</strong>
</strong>{' '}
{renderTotalMaxAmount()}
</div>
}
@ -863,7 +864,7 @@ export default class ConfirmTransactionBase extends Component {
value={hexTransactionAmount}
type={PRIMARY}
showEthLogo
ethLogoHeight="26"
ethLogoHeight="36"
hideLabel
/>
);
@ -1004,6 +1005,7 @@ export default class ConfirmTransactionBase extends Component {
gasFeeIsCustom,
nativeCurrency,
hardwareWalletRequiresConnection,
image,
} = this.props;
const {
submitting,
@ -1060,6 +1062,7 @@ export default class ConfirmTransactionBase extends Component {
showEdit={Boolean(onEdit)}
action={functionType}
title={title}
image={image}
titleComponent={this.renderTitleComponent()}
subtitleComponent={this.renderSubtitleComponent()}
hideSubtitle={hideSubtitle}

View File

@ -14,7 +14,11 @@ import {
setDefaultHomeActiveTabName,
} from '../../store/actions';
import { isBalanceSufficient, calcGasTotal } from '../send/send.utils';
import { shortenAddress, valuesFor } from '../../helpers/utils/util';
import {
isEqualCaseInsensitive,
shortenAddress,
valuesFor,
} from '../../helpers/utils/util';
import {
getAdvancedInlineGasShown,
getCustomNonceValue,
@ -82,6 +86,8 @@ const mapStateToProps = (state, ownProps) => {
network,
unapprovedTxs,
nextNonce,
allCollectibleContracts,
selectedAddress,
provider: { chainId },
} = metamask;
const { tokenData, txData, tokenProps, nonce } = confirmTransaction;
@ -168,6 +174,13 @@ const mapStateToProps = (state, ownProps) => {
},
};
}
const isCollectibleTransfer = Boolean(
allCollectibleContracts?.[selectedAddress]?.[chainId].find((contract) => {
return isEqualCaseInsensitive(contract.address, fullTxData.txParams.to);
}),
);
customNonceValue = getCustomNonceValue(state);
const isEthGasPrice = getIsEthGasPriceFetched(state);
const noGasPrice = !supportsEIP1559 && getNoGasPriceFetched(state);
@ -215,7 +228,7 @@ const mapStateToProps = (state, ownProps) => {
useNonceField: getUseNonceField(state),
customNonceValue,
insufficientBalance,
hideSubtitle: !getShouldShowFiat(state),
hideSubtitle: !getShouldShowFiat(state) && !isCollectibleTransfer,
hideFiatConversion: !getShouldShowFiat(state),
type,
nextNonce,

View File

@ -42,7 +42,11 @@ export default class SendAmountRow extends Component {
}
render() {
const { inError } = this.props;
const { inError, asset } = this.props;
if (asset.type === ASSET_TYPES.COLLECTIBLE) {
return null;
}
return (
<SendRowWrapper

View File

@ -1,7 +1,7 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import SendRowWrapper from '../send-row-wrapper';
import Identicon from '../../../../components/ui/identicon/identicon.component';
import Identicon from '../../../../components/ui/identicon';
import TokenBalance from '../../../../components/ui/token-balance';
import UserPreferencedCurrencyDisplay from '../../../../components/app/user-preferenced-currency-display';
import { ERC20, PRIMARY } from '../../../../helpers/constants/common';
@ -20,10 +20,27 @@ export default class SendAssetRow extends Component {
).isRequired,
accounts: PropTypes.object.isRequired,
selectedAddress: PropTypes.string.isRequired,
sendAssetAddress: PropTypes.string,
sendAsset: PropTypes.object,
updateSendAsset: PropTypes.func.isRequired,
nativeCurrency: PropTypes.string,
nativeCurrencyImage: PropTypes.string,
collectibles: PropTypes.arrayOf(
PropTypes.shape({
address: PropTypes.string.isRequired,
tokenId: PropTypes.string.isRequired,
name: PropTypes.string,
description: PropTypes.string,
image: PropTypes.string,
standard: PropTypes.string,
imageThumbnail: PropTypes.string,
imagePreview: PropTypes.string,
creator: PropTypes.shape({
address: PropTypes.string,
config: PropTypes.string,
profile_img_url: PropTypes.string,
}),
}),
),
};
static contextTypes = {
@ -34,17 +51,34 @@ export default class SendAssetRow extends Component {
state = {
isShowingDropdown: false,
sendableTokens: [],
sendableCollectibles: [],
};
async componentDidMount() {
const sendableTokens = this.props.tokens.filter((token) => !token.isERC721);
this.setState({ sendableTokens });
const sendableCollectibles = this.props.collectibles.filter(
(collectible) => collectible.isCurrentlyOwned,
);
this.setState({ sendableTokens, sendableCollectibles });
}
openDropdown = () => this.setState({ isShowingDropdown: true });
closeDropdown = () => this.setState({ isShowingDropdown: false });
getAssetSelected = (type, token) => {
switch (type) {
case ASSET_TYPES.NATIVE:
return this.props.nativeCurrency;
case ASSET_TYPES.TOKEN:
return ERC20;
case ASSET_TYPES.COLLECTIBLE:
return token?.standard;
default:
return null;
}
};
selectToken = (type, token) => {
this.setState(
{
@ -58,7 +92,7 @@ export default class SendAssetRow extends Component {
name: 'User clicks "Assets" dropdown',
},
customVariables: {
assetSelected: token ? ERC20 : this.props.nativeCurrency,
assetSelected: this.getAssetSelected(type, token),
},
});
this.props.updateSendAsset({
@ -75,8 +109,14 @@ export default class SendAssetRow extends Component {
return (
<SendRowWrapper label={`${t('asset')}:`}>
<div className="send-v2__asset-dropdown">
{this.renderSendToken()}
{this.state.sendableTokens.length > 0
<div
className="send-v2__asset-dropdown__input-wrapper"
onClick={this.openDropdown}
>
{this.renderSendAsset()}
</div>
{[...this.state.sendableTokens, ...this.state.sendableCollectibles]
.length > 0
? this.renderAssetDropdown()
: null}
</div>
@ -84,19 +124,31 @@ export default class SendAssetRow extends Component {
);
}
renderSendToken() {
const { sendAssetAddress } = this.props;
const token = this.props.tokens.find(({ address }) =>
isEqualCaseInsensitive(address, sendAssetAddress),
);
return (
<div
className="send-v2__asset-dropdown__input-wrapper"
onClick={this.openDropdown}
>
{token ? this.renderAsset(token) : this.renderNativeCurrency()}
</div>
);
renderSendAsset() {
const {
sendAsset: { details, type },
tokens,
collectibles,
} = this.props;
if (type === ASSET_TYPES.TOKEN) {
const token = tokens.find(({ address }) =>
isEqualCaseInsensitive(address, details.address),
);
if (token) {
return this.renderToken(token);
}
} else if (type === ASSET_TYPES.COLLECTIBLE) {
const collectible = collectibles.find(
({ address, tokenId }) =>
isEqualCaseInsensitive(address, details.address) &&
tokenId === details.tokenId,
);
if (collectible) {
return this.renderCollectible(collectible);
}
}
return this.renderNativeCurrency();
}
renderAssetDropdown() {
@ -110,7 +162,10 @@ export default class SendAssetRow extends Component {
<div className="send-v2__asset-dropdown__list">
{this.renderNativeCurrency(true)}
{this.state.sendableTokens.map((token) =>
this.renderAsset(token, true),
this.renderToken(token, true),
)}
{this.state.sendableCollectibles.map((collectible) =>
this.renderCollectible(collectible, true),
)}
</div>
</div>
@ -127,14 +182,17 @@ export default class SendAssetRow extends Component {
nativeCurrencyImage,
} = this.props;
const { sendableTokens, sendableCollectibles } = this.state;
const balanceValue = accounts[selectedAddress]
? accounts[selectedAddress].balance
: '';
const sendableAssets = [...sendableTokens, ...sendableCollectibles];
return (
<div
className={
this.state.sendableTokens.length > 0
sendableAssets.length > 0
? 'send-v2__asset-dropdown__asset'
: 'send-v2__asset-dropdown__single-asset'
}
@ -161,14 +219,14 @@ export default class SendAssetRow extends Component {
/>
</div>
</div>
{!insideDropdown && this.state.sendableTokens.length > 0 && (
{!insideDropdown && sendableAssets.length > 0 && (
<i className="fa fa-caret-down fa-lg send-v2__asset-dropdown__caret" />
)}
</div>
);
}
renderAsset(token, insideDropdown = false) {
renderToken(token, insideDropdown = false) {
const { address, symbol, image } = token;
const { t } = this.context;
@ -196,4 +254,33 @@ export default class SendAssetRow extends Component {
</div>
);
}
renderCollectible(collectible, insideDropdown = false) {
const { address, name, image, tokenId } = collectible;
const { t } = this.context;
return (
<div
key={address}
className="send-v2__asset-dropdown__asset"
onClick={() => this.selectToken(ASSET_TYPES.COLLECTIBLE, collectible)}
>
<div className="send-v2__asset-dropdown__asset-icon">
<Identicon address={address} diameter={36} image={image} />
</div>
<div className="send-v2__asset-dropdown__asset-data">
<div className="send-v2__asset-dropdown__symbol">{name}</div>
<div className="send-v2__asset-dropdown__name">
<span className="send-v2__asset-dropdown__name__label">
{`${t('tokenId')}:`}
</span>
{tokenId}
</div>
</div>
{!insideDropdown && (
<i className="fa fa-caret-down fa-lg send-v2__asset-dropdown__caret" />
)}
</div>
);
}
}

View File

@ -1,17 +1,21 @@
import { connect } from 'react-redux';
import { getNativeCurrency } from '../../../../ducks/metamask/metamask';
import {
getCollectibles,
getNativeCurrency,
} from '../../../../ducks/metamask/metamask';
import {
getMetaMaskAccounts,
getNativeCurrencyImage,
} from '../../../../selectors';
import { updateSendAsset, getSendAssetAddress } from '../../../../ducks/send';
import { updateSendAsset, getSendAsset } from '../../../../ducks/send';
import SendAssetRow from './send-asset-row.component';
function mapStateToProps(state) {
return {
tokens: state.metamask.tokens,
selectedAddress: state.metamask.selectedAddress,
sendAssetAddress: getSendAssetAddress(state),
collectibles: getCollectibles(state),
sendAsset: getSendAsset(state),
accounts: getMetaMaskAccounts(state),
nativeCurrency: getNativeCurrency(state),
nativeCurrencyImage: getNativeCurrencyImage(state),

View File

@ -7,7 +7,6 @@ import {
ETH_GAS_PRICE_FETCH_WARNING_KEY,
GAS_PRICE_FETCH_FAILURE_ERROR_KEY,
GAS_PRICE_EXCESSIVE_ERROR_KEY,
UNSENDABLE_ASSET_ERROR_KEY,
INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY,
} from '../../../helpers/constants/error-keys';
import { ASSET_TYPES } from '../../../ducks/send';
@ -26,7 +25,6 @@ export default class SendContent extends Component {
};
static propTypes = {
isAssetSendable: PropTypes.bool,
showHexData: PropTypes.bool,
contact: PropTypes.object,
isOwnedAccount: PropTypes.bool,
@ -48,7 +46,6 @@ export default class SendContent extends Component {
gasIsExcessive,
isEthGasPrice,
noGasPrice,
isAssetSendable,
networkOrAccountNotSupports1559,
getIsBalanceInsufficient,
asset,
@ -63,7 +60,9 @@ export default class SendContent extends Component {
gasError = INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY;
}
const showHexData =
this.props.showHexData && asset.type !== ASSET_TYPES.TOKEN;
this.props.showHexData &&
asset.type !== ASSET_TYPES.TOKEN &&
asset.type !== ASSET_TYPES.COLLECTIBLE;
return (
<PageContainerContent>
@ -72,9 +71,6 @@ export default class SendContent extends Component {
{isEthGasPrice
? this.renderWarning(ETH_GAS_PRICE_FETCH_WARNING_KEY)
: null}
{isAssetSendable === false
? this.renderError(UNSENDABLE_ASSET_ERROR_KEY)
: null}
{error ? this.renderError(error) : null}
{warning ? this.renderWarning() : null}
{this.maybeRenderAddContact()}

View File

@ -7,7 +7,6 @@ import {
checkNetworkOrAccountNotSupports1559,
} from '../../../selectors';
import {
getIsAssetSendable,
getIsBalanceInsufficient,
getSendTo,
getSendAsset,
@ -19,7 +18,6 @@ function mapStateToProps(state) {
const ownedAccounts = accountsWithSendEtherInfoSelector(state);
const to = getSendTo(state);
return {
isAssetSendable: getIsAssetSendable(state),
isOwnedAccount: Boolean(
ownedAccounts.find(
({ address }) => address.toLowerCase() === to.toLowerCase(),

View File

@ -28,6 +28,7 @@ const MIN_GAS_TOTAL = multiplyCurrencies(MIN_GAS_LIMIT_HEX, MIN_GAS_PRICE_HEX, {
});
const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb';
const COLLECTIBLE_TRANSFER_FROM_FUNCTION_SIGNATURE = '0x23b872dd';
const INSUFFICIENT_FUNDS_ERROR = 'insufficientFunds';
const INSUFFICIENT_TOKENS_ERROR = 'insufficientTokens';
@ -71,4 +72,5 @@ export {
REQUIRED_ERROR,
CONFUSING_ENS_ERROR,
TOKEN_TRANSFER_FUNCTION_SIGNATURE,
COLLECTIBLE_TRANSFER_FROM_FUNCTION_SIGNATURE,
};

View File

@ -10,13 +10,18 @@ import {
import { calcTokenAmount } from '../../helpers/utils/token-util';
import { addHexPrefix } from '../../../app/scripts/lib/util';
import { TOKEN_TRANSFER_FUNCTION_SIGNATURE } from './send.constants';
import { ERC20, ERC721 } from '../../helpers/constants/common';
import {
TOKEN_TRANSFER_FUNCTION_SIGNATURE,
COLLECTIBLE_TRANSFER_FROM_FUNCTION_SIGNATURE,
} from './send.constants';
export {
addGasBuffer,
calcGasTotal,
generateTokenTransferData,
getAssetTransferData,
generateERC20TransferData,
generateERC721TransferData,
isBalanceSufficient,
isTokenBalanceSufficient,
ellipsify,
@ -123,7 +128,7 @@ function addGasBuffer(
return upperGasLimit;
}
function generateTokenTransferData({
function generateERC20TransferData({
toAddress = '0x0',
amount = '0x0',
sendToken,
@ -145,6 +150,46 @@ function generateTokenTransferData({
);
}
function generateERC721TransferData({
toAddress = '0x0',
fromAddress = '0x0',
tokenId,
}) {
if (!tokenId) {
return undefined;
}
return (
COLLECTIBLE_TRANSFER_FROM_FUNCTION_SIGNATURE +
Array.prototype.map
.call(
abi.rawEncode(
['address', 'address', 'uint256'],
[fromAddress, toAddress, tokenId],
),
(x) => `00${x.toString(16)}`.slice(-2),
)
.join('')
);
}
function getAssetTransferData({ sendToken, fromAddress, toAddress, amount }) {
switch (sendToken.standard) {
case ERC721:
return generateERC721TransferData({
toAddress,
fromAddress,
tokenId: sendToken.tokenId,
});
case ERC20:
default:
return generateERC20TransferData({
toAddress,
amount,
sendToken,
});
}
}
function ellipsify(text, first = 6, last = 4) {
return `${text.slice(0, first)}...${text.slice(-last)}`;
}

View File

@ -9,7 +9,7 @@ import {
import {
calcGasTotal,
generateTokenTransferData,
generateERC20TransferData,
isBalanceSufficient,
isTokenBalanceSufficient,
} from './send.utils';
@ -53,10 +53,10 @@ describe('send utils', () => {
});
});
describe('generateTokenTransferData()', () => {
describe('generateERC20TransferData()', () => {
it('should return undefined if not passed a send token', () => {
expect(
generateTokenTransferData({
generateERC20TransferData({
toAddress: 'mockAddress',
amount: '0xa',
sendToken: undefined,
@ -65,7 +65,7 @@ describe('send utils', () => {
});
it('should call abi.rawEncode with the correct params', () => {
generateTokenTransferData({
generateERC20TransferData({
toAddress: 'mockAddress',
amount: 'ab',
sendToken: { address: '0x0' },
@ -80,7 +80,7 @@ describe('send utils', () => {
it('should return encoded token transfer data', () => {
expect(
generateTokenTransferData({
generateERC20TransferData({
toAddress: 'mockAddress',
amount: '0xa',
sendToken: { address: '0x0' },

View File

@ -68,7 +68,7 @@ import {
SWAPS_ERROR_ROUTE,
AWAITING_SWAP_ROUTE,
} from '../../../helpers/constants/routes';
import { getTokenData } from '../../../helpers/utils/transactions.util';
import { getTransactionData } from '../../../helpers/utils/transactions.util';
import {
calcTokenAmount,
calcTokenValue,
@ -243,7 +243,7 @@ export default function ViewQuote() {
const tokenBalanceUnavailable =
tokensWithBalances && balanceToken === undefined;
const approveData = getTokenData(approveTxParams?.data);
const approveData = getTransactionData(approveTxParams?.data);
const approveValue = approveData && getTokenValueParam(approveData);
const approveAmount =
approveValue &&

View File

@ -1393,8 +1393,29 @@ export function removeCollectible(address, tokenID, dontShowLoadingIndicator) {
};
}
export async function checkAndUpdateCollectiblesOwnershipStatus() {
await promisifiedBackground.checkAndUpdateCollectiblesOwnershipStatus();
export async function checkAndUpdateAllCollectiblesOwnershipStatus() {
await promisifiedBackground.checkAndUpdateAllCollectiblesOwnershipStatus();
}
export async function isCollectibleOwner(
ownerAddress,
collectibleAddress,
collectibleId,
) {
return await promisifiedBackground.isCollectibleOwner(
ownerAddress,
collectibleAddress,
collectibleId,
);
}
export async function checkAndUpdateSingleCollectibleOwnershipStatus(
collectible,
) {
await promisifiedBackground.checkAndUpdateSingleCollectibleOwnershipStatus(
collectible,
false,
);
}
export function removeToken(address) {

View File

@ -2652,14 +2652,15 @@
web3 "^0.20.7"
web3-provider-engine "^16.0.3"
"@metamask/controllers@^23.0.0":
version "23.0.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-23.0.0.tgz#81ea9fa37924a14b08668f37e7e31f46091aa610"
integrity sha512-6hKh5H0HM1YLTOdfuD8gRXGCG3brEhTagup204SrlmwabTReaIIb/zXTat7jCs6ZvN362a44GO5mboZ6W9MhIA==
"@metamask/controllers@^24.0.0":
version "24.0.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-24.0.0.tgz#380d4e10bd9b903afc38b041ea7e64b30ae34dab"
integrity sha512-gOTpvxQTNTrXOUpGOiRKcqHUgnjSiTgJcwNLxenZWqPIixz7eI2qHnjO7ZaGbV/baK7SOa4rRGokvsCNV0mafA==
dependencies:
"@ethereumjs/common" "^2.3.1"
"@ethereumjs/tx" "^3.2.1"
"@metamask/contract-metadata" "^1.31.0"
"@metamask/metamask-eth-abis" "^2.1.0"
"@types/uuid" "^8.3.0"
abort-controller "^3.0.0"
async-mutex "^0.2.6"
@ -2676,9 +2677,6 @@
ethereumjs-wallet "^1.0.1"
ethers "^5.4.1"
ethjs-unit "^0.1.6"
human-standard-collectible-abi "^1.0.2"
human-standard-multi-collectible-abi "^1.0.4"
human-standard-token-abi "^2.0.0"
immer "^9.0.6"
isomorphic-fetch "^3.0.0"
jsonschema "^1.2.4"
@ -2768,6 +2766,11 @@
gl-mat4 "1.1.4"
gl-vec3 "1.0.3"
"@metamask/metamask-eth-abis@^2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@metamask/metamask-eth-abis/-/metamask-eth-abis-2.1.0.tgz#316c2e72373506f1a0120b76e432760a27eb6806"
integrity sha512-T8LBEB0PQo0N1tZQKZ2K8BGmv+IDLcXkzt8Pn7x0YnwZD6YpCIvKqYM3iy2fJ6wFXeCvRKqpn4K6EqwnkSJAbQ==
"@metamask/object-multiplex@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@metamask/object-multiplex/-/object-multiplex-1.1.0.tgz#6b1507c4d10caafd2ea82dd2a5360b91631e036e"
@ -14761,11 +14764,6 @@ human-standard-collectible-abi@^1.0.2:
resolved "https://registry.yarnpkg.com/human-standard-collectible-abi/-/human-standard-collectible-abi-1.0.2.tgz#077bae9ed1b0b0b82bc46932104b4b499c941aa0"
integrity sha512-nD3ITUuSAIBgkaCm9J2BGwlHL8iEzFjJfTleDAC5Wi8RBJEXXhxV0JeJjd95o+rTwf98uTE5MW+VoBKOIYQh0g==
human-standard-multi-collectible-abi@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/human-standard-multi-collectible-abi/-/human-standard-multi-collectible-abi-1.0.4.tgz#981625bc1a6bea5fef90567f9e12c11581fac497"
integrity sha512-ylR9JDXClDJAxWD/QJxsjXJJdLTUmhipTquMAgrfybXL3qX3x3P/vmKg92A7qFu7SqVOf2hyv5dA8vX0j+0Thg==
human-standard-token-abi@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/human-standard-token-abi/-/human-standard-token-abi-1.0.2.tgz#207d7846796ee5bb85fdd336e769cb38045b2ae0"