From a2499a6cf19fdd169921f998df1acfa78fdb5589 Mon Sep 17 00:00:00 2001 From: seaona Date: Mon, 11 Jul 2022 10:34:42 +0200 Subject: [PATCH 01/14] Auto-generated Changelog --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a7895a826..9d5c7662b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Uncategorized +- Master sync following v10.17.0 ([#15126](https://github.com/MetaMask/metamask-extension/pull/15126)) +- Disable Seedphrase import button after switching seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) +- Snaps E2E Test Upgrades for the new test-snaps UI ([#14980](https://github.com/MetaMask/metamask-extension/pull/14980)) +- The Japanese word for sign is "署名" ([#15078](https://github.com/MetaMask/metamask-extension/pull/15078)) +- Add feature flag to prevent add popular networks from being available on prod ([#15117](https://github.com/MetaMask/metamask-extension/pull/15117)) +- Bump minimum Node.js version to 16 ([#15131](https://github.com/MetaMask/metamask-extension/pull/15131)) +- Update Optimism ChainID from Kovan to Goerli ([#15119](https://github.com/MetaMask/metamask-extension/pull/15119)) +- fix Chinese translation ([#14994](https://github.com/MetaMask/metamask-extension/pull/14994)) +- State logs e2e ([#15123](https://github.com/MetaMask/metamask-extension/pull/15123)) +- Send to hex-prefixed address e2e tests ([#15111](https://github.com/MetaMask/metamask-extension/pull/15111)) +- Merge remote-tracking branch 'origin/develop' into master-sync +- Update wallet-overview.js ([#15125](https://github.com/MetaMask/metamask-extension/pull/15125)) +- Remove global transaction state from send flow ([#14777](https://github.com/MetaMask/metamask-extension/pull/14777)) +- Ensure native asset icon does not display incorrectly when switching networks and balance is loading ([#15116](https://github.com/MetaMask/metamask-extension/pull/15116)) +- Master Sync PR following v10.16.2 ([#15108](https://github.com/MetaMask/metamask-extension/pull/15108)) +- Adding popular custom network integration ([#14557](https://github.com/MetaMask/metamask-extension/pull/14557)) +- Add fallback image/card for NFTs when image was not fetched correctly or does not exist ([#15034](https://github.com/MetaMask/metamask-extension/pull/15034)) +- Merge remote-tracking branch 'origin/develop' into master-sync +- Bump @metamask/design-tokens from 1.6.5 to 1.7.0 ([#15017](https://github.com/MetaMask/metamask-extension/pull/15017)) +- Master Sync after v10.16.1 ([#15079](https://github.com/MetaMask/metamask-extension/pull/15079)) +- Update CHANGELOG.md +- Merge remote-tracking branch 'origin/develop' into master-sync +- Remove change from cb77f94 that breaks ens inputs in send flow ([#15069](https://github.com/MetaMask/metamask-extension/pull/15069)) +- Fix tokenIds larger than MAX_SAFE_INTEGER converted to scientific notation and failing to import ([#15016](https://github.com/MetaMask/metamask-extension/pull/15016)) +- Chromedriver v103 ([#15015](https://github.com/MetaMask/metamask-extension/pull/15015)) ## [10.17.0] ### Added From 550103a5b0af64c8ea09da1c790eef31b5409d24 Mon Sep 17 00:00:00 2001 From: MetaMask Bot Date: Mon, 11 Jul 2022 08:38:29 +0000 Subject: [PATCH 02/14] Version v10.18.0 --- CHANGELOG.md | 10 +++++++++- package.json | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d5c7662b..f7ae5ae4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] + +## [10.18.0] ### Uncategorized +- Auto-generated Changelog +- Merge remote-tracking branch 'origin/develop' into master-sync +- Merge remote-tracking branch 'origin/develop' into master-sync +- Update CHANGELOG.md +- Merge remote-tracking branch 'origin/develop' into master-sync - Master sync following v10.17.0 ([#15126](https://github.com/MetaMask/metamask-extension/pull/15126)) - Disable Seedphrase import button after switching seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) - Snaps E2E Test Upgrades for the new test-snaps UI ([#14980](https://github.com/MetaMask/metamask-extension/pull/14980)) @@ -3094,7 +3101,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Uncategorized - Added the ability to restore accounts from seed words. -[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.17.0...HEAD +[Unreleased]: https://github.com/MetaMask/metamask-extension/compare/v10.18.0...HEAD +[10.18.0]: https://github.com/MetaMask/metamask-extension/compare/v10.17.0...v10.18.0 [10.17.0]: https://github.com/MetaMask/metamask-extension/compare/v10.16.2...v10.17.0 [10.16.2]: https://github.com/MetaMask/metamask-extension/compare/v10.16.1...v10.16.2 [10.16.1]: https://github.com/MetaMask/metamask-extension/compare/v10.16.0...v10.16.1 diff --git a/package.json b/package.json index dc74eafb2..828853ae2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metamask-crx", - "version": "10.17.0", + "version": "10.18.0", "private": true, "repository": { "type": "git", From 8d20c2834fee8ae5349dbfad9531b75ae2ffa1df Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Mon, 11 Jul 2022 18:32:55 -0500 Subject: [PATCH 03/14] Add setApprovalForAll confirmation view (#15010) * enhance setApprovalForAll confirmation flow * cleanup * address feedback --- app/_locales/en/messages.json | 26 ++++++++ shared/constants/transaction.js | 3 + shared/modules/transaction.utils.js | 5 +- ...onfirm-page-container-summary.component.js | 3 +- ...transaction-list-item-details.component.js | 5 +- ui/helpers/constants/routes.js | 3 + ui/helpers/constants/transactions.js | 1 + ui/helpers/utils/token-util.js | 4 ++ ui/helpers/utils/transactions.util.js | 4 ++ ui/hooks/useTransactionDisplayData.js | 6 ++ .../confirm-approve-content.component.js | 64 +++++++++++++++---- ui/pages/confirm-approve/confirm-approve.js | 15 ++++- .../confirm-transaction-base.component.js | 1 + .../confirm-transaction-switch.component.js | 5 ++ .../confirm-token-transaction-switch.js | 25 ++++++++ 15 files changed, 154 insertions(+), 16 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index e7fe44c60..78bb505b8 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -230,6 +230,10 @@ "alerts": { "message": "Alerts" }, + "allOfYour": { + "message": "All of your $1", + "description": "$1 is the symbol or name of the token that the user is approving spending" + }, "allowExternalExtensionTo": { "message": "Allow this external extension to:" }, @@ -266,6 +270,10 @@ "approve": { "message": "Approve spend limit" }, + "approveAllTokensTitle": { + "message": "Give permission to access all of your $1?", + "description": "$1 is the symbol of the token for which the user is granting approval" + }, "approveAndInstall": { "message": "Approve & Install" }, @@ -1287,6 +1295,9 @@ "functionApprove": { "message": "Function: Approve" }, + "functionSetApprovalForAll": { + "message": "Function: SetApprovalForAll" + }, "functionType": { "message": "Function Type" }, @@ -2696,6 +2707,14 @@ "revealTheSeedPhrase": { "message": "Reveal seed phrase" }, + "revokeAllTokensTitle": { + "message": "Revoke permission to access all of your $1?", + "description": "$1 is the symbol of the token for which the user is revoking approval" + }, + "revokeApproveForAllDescription": { + "message": "By revoking permission, the following $1 will no longer be able to access your $2", + "description": "$1 is either key 'account' or 'contract', and $2 is either a string or link of a given token symbol or name" + }, "rinkeby": { "message": "Rinkeby Test Network" }, @@ -2878,6 +2897,13 @@ "setAdvancedPrivacySettingsDetails": { "message": "MetaMask uses these trusted third-party services to enhance product usability and safety." }, + "setApprovalForAll": { + "message": "Set Approval for All" + }, + "setApprovalForAllTitle": { + "message": "Approve $1 with no spend limit", + "description": "The token symbol that is being approved" + }, "settings": { "message": "Settings" }, diff --git a/shared/constants/transaction.js b/shared/constants/transaction.js index 8cb365f79..d2a54ae35 100644 --- a/shared/constants/transaction.js +++ b/shared/constants/transaction.js @@ -15,6 +15,8 @@ import { MESSAGE_TYPE } from './app'; * to ensure that the receiver is an address capable of handling with the token being sent. * @property {'approve'} TOKEN_METHOD_APPROVE - A token transaction requesting an * allowance of the token to spend on behalf of the user + * @property {'setapprovalforall'} TOKEN_METHOD_SET_APPROVAL_FOR_ALL - A token transaction requesting an + * allowance of all of a user's token to spend on behalf of the user * @property {'incoming'} INCOMING - An incoming (deposit) transaction * @property {'simpleSend'} SIMPLE_SEND - A transaction sending a network's native asset to a recipient * @property {'contractInteraction'} CONTRACT_INTERACTION - A transaction that is @@ -66,6 +68,7 @@ export const TRANSACTION_TYPES = { TOKEN_METHOD_SAFE_TRANSFER_FROM: 'safetransferfrom', TOKEN_METHOD_TRANSFER: 'transfer', TOKEN_METHOD_TRANSFER_FROM: 'transferfrom', + TOKEN_METHOD_SET_APPROVAL_FOR_ALL: 'setapprovalforall', }; /** diff --git a/shared/modules/transaction.utils.js b/shared/modules/transaction.utils.js index 08adb46e3..1910ef2f3 100644 --- a/shared/modules/transaction.utils.js +++ b/shared/modules/transaction.utils.js @@ -8,7 +8,7 @@ import { readAddressAsContract } from './contract-utils'; import { isEqualCaseInsensitive } from './string-utils'; /** - * @typedef { 'transfer' | 'approve' | 'transferfrom' | 'contractInteraction'| 'simpleSend' } InferrableTransactionTypes + * @typedef { 'transfer' | 'approve' | 'setapprovalforall' | 'transferfrom' | 'contractInteraction'| 'simpleSend' } InferrableTransactionTypes */ /** @@ -150,6 +150,7 @@ export async function determineTransactionType(txParams, query) { const tokenMethodName = [ TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, @@ -181,6 +182,7 @@ export async function determineTransactionType(txParams, query) { const INFERRABLE_TRANSACTION_TYPES = [ TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.CONTRACT_INTERACTION, @@ -220,6 +222,7 @@ export async function determineTransactionAssetType( // method to get the asset type. const isTokenMethod = [ TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, ].find((methodName) => methodName === inferrableType); diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js index 2d5fb8193..e31e81bce 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-summary/confirm-page-container-summary.component.js @@ -53,7 +53,8 @@ const ConfirmPageContainerSummary = (props) => { contractAddress = transactionType === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER || transactionType === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM || - transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM + transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM || + transactionType === TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL ? tokenAddress : toAddress; } diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js index 331885364..f65c82f63 100644 --- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js +++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js @@ -251,7 +251,10 @@ export default class TransactionListItemDetails extends PureComponent {
param.name === '_value'); return valueData && valueData.value; diff --git a/ui/helpers/utils/transactions.util.js b/ui/helpers/utils/transactions.util.js index d1380d8c0..07d37341d 100644 --- a/ui/helpers/utils/transactions.util.js +++ b/ui/helpers/utils/transactions.util.js @@ -116,6 +116,7 @@ export function isTokenMethodAction(type) { return [ TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, ].includes(type); @@ -217,6 +218,9 @@ export function getTransactionTypeTitle(t, type, nativeCurrency = 'ETH') { case TRANSACTION_TYPES.TOKEN_METHOD_APPROVE: { return t('approve'); } + case TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL: { + return t('setApprovalForAll'); + } case TRANSACTION_TYPES.SIMPLE_SEND: { return t('sendingNativeAsset', [nativeCurrency]); } diff --git a/ui/hooks/useTransactionDisplayData.js b/ui/hooks/useTransactionDisplayData.js index 184374496..9c6a13768 100644 --- a/ui/hooks/useTransactionDisplayData.js +++ b/ui/hooks/useTransactionDisplayData.js @@ -222,6 +222,12 @@ export function useTransactionDisplayData(transactionGroup) { title = t('approveSpendLimit', [token?.symbol || t('token')]); subtitle = origin; subtitleContainsOrigin = true; + } else if (type === TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL) { + category = TRANSACTION_GROUP_CATEGORIES.APPROVAL; + prefix = ''; + title = t('setApprovalForAllTitle', [token?.symbol || t('token')]); + subtitle = origin; + subtitleContainsOrigin = true; } else if (type === TRANSACTION_TYPES.CONTRACT_INTERACTION) { category = TRANSACTION_GROUP_CATEGORIES.INTERACTION; const transactionTypeTitle = getTransactionTypeTitle(t, type); diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index 7a6d32cce..cb831bbd3 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -71,6 +71,8 @@ export default class ConfirmApproveContent extends Component { assetName: PropTypes.string, tokenId: PropTypes.string, assetStandard: PropTypes.string, + isSetApproveForAll: PropTypes.bool, + setApproveForAllArg: PropTypes.bool, }; state = { @@ -184,7 +186,7 @@ export default class ConfirmApproveContent extends Component { renderERC721OrERC1155PermissionContent() { const { t } = this.context; - const { origin, toAddress, isContract } = this.props; + const { origin, toAddress, isContract, isSetApproveForAll } = this.props; const titleTokenDescription = this.getTitleTokenDescription(); @@ -201,7 +203,9 @@ export default class ConfirmApproveContent extends Component { {t('approvedAsset')}:
- {titleTokenDescription} + {isSetApproveForAll + ? `${t('allOfYour', [titleTokenDescription])} ` + : titleTokenDescription}
@@ -299,12 +303,19 @@ export default class ConfirmApproveContent extends Component { renderDataContent() { const { t } = this.context; - const { data } = this.props; + const { data, isSetApproveForAll, setApproveForAllArg } = this.props; return (
- {t('functionApprove')} + {isSetApproveForAll + ? t('functionSetApprovalForAll') + : t('functionApprove')}
+ {isSetApproveForAll && setApproveForAllArg !== undefined ? ( +
+ {t('parameters')}: {setApproveForAllArg} +
+ ) : null}
{data}
@@ -509,6 +520,41 @@ export default class ConfirmApproveContent extends Component { return titleTokenDescription; } + renderTitle() { + const { t } = this.context; + const { isSetApproveForAll, setApproveForAllArg } = this.props; + const titleTokenDescription = this.getTitleTokenDescription(); + + let title; + + if (isSetApproveForAll) { + title = t('approveAllTokensTitle', [titleTokenDescription]); + if (setApproveForAllArg === false) { + title = t('revokeAllTokensTitle', [titleTokenDescription]); + } + } + return title || t('allowSpendToken', [titleTokenDescription]); + } + + renderDescription() { + const { t } = this.context; + const { isContract, isSetApproveForAll, setApproveForAllArg } = this.props; + const grantee = isContract + ? t('contract').toLowerCase() + : t('account').toLowerCase(); + + let description = t('trustSiteApprovePermission', [grantee]); + + if (isSetApproveForAll && setApproveForAllArg === false) { + description = t('revokeApproveForAllDescription', [ + grantee, + this.getTitleTokenDescription(), + ]); + } + + return description; + } + render() { const { t } = this.context; const { @@ -534,8 +580,6 @@ export default class ConfirmApproveContent extends Component { } = this.props; const { showFullTxDetails } = this.state; - const titleTokenDescription = this.getTitleTokenDescription(); - return (
- {t('allowSpendToken', [titleTokenDescription])} + {this.renderTitle()}
- {t('trustSiteApprovePermission', [ - isContract - ? t('contract').toLowerCase() - : t('account').toLowerCase(), - ])} + {this.renderDescription()}
diff --git a/ui/pages/confirm-approve/confirm-approve.js b/ui/pages/confirm-approve/confirm-approve.js index 6dca97d1b..c1b35c24d 100644 --- a/ui/pages/confirm-approve/confirm-approve.js +++ b/ui/pages/confirm-approve/confirm-approve.js @@ -8,7 +8,10 @@ import { updateCustomNonce, getNextNonce, } from '../../store/actions'; -import { calcTokenAmount } from '../../helpers/utils/token-util'; +import { + calcTokenAmount, + getTokenApprovedParam, +} from '../../helpers/utils/token-util'; import { readAddressAsContract } from '../../../shared/modules/contract-utils'; import { GasFeeContextProvider } from '../../contexts/gasFee'; import { TransactionModalContextProvider } from '../../contexts/transaction-modal'; @@ -34,6 +37,7 @@ import EditGasFeePopover from '../../components/app/edit-gas-fee-popover'; import EditGasPopover from '../../components/app/edit-gas-popover/edit-gas-popover.component'; import Loading from '../../components/ui/loading-screen'; import { ERC20, ERC1155, ERC721 } from '../../helpers/constants/common'; +import { parseStandardTokenTransactionData } from '../../../shared/modules/transaction.utils'; import { getCustomTxParamsData } from './confirm-approve.util'; import ConfirmApproveContent from './confirm-approve-content'; @@ -57,6 +61,7 @@ export default function ConfirmApprove({ ethTransactionTotal, fiatTransactionTotal, hexTransactionTotal, + isSetApproveForAll, }) { const dispatch = useDispatch(); const { txParams: { data: transactionData } = {} } = transaction; @@ -150,6 +155,11 @@ export default function ConfirmApprove({ }) : null; + const parsedTransactionData = parseStandardTokenTransactionData( + transactionData, + ); + const setApproveForAllArg = getTokenApprovedParam(parsedTransactionData); + return tokenSymbol === undefined && assetName === undefined ? ( ) : ( @@ -162,6 +172,8 @@ export default function ConfirmApprove({ contentComponent={ ; } + case TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL: { + const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_SET_APPROVAL_FOR_ALL_PATH}`; + return ; + } case TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM: { const pathname = `${CONFIRM_TRANSACTION_ROUTE}/${id}${CONFIRM_TRANSFER_FROM_PATH}`; return ; diff --git a/ui/pages/confirm-transaction/confirm-token-transaction-switch.js b/ui/pages/confirm-transaction/confirm-token-transaction-switch.js index 036f2b149..43b554595 100644 --- a/ui/pages/confirm-transaction/confirm-token-transaction-switch.js +++ b/ui/pages/confirm-transaction/confirm-token-transaction-switch.js @@ -6,6 +6,7 @@ import { CONFIRM_APPROVE_PATH, CONFIRM_SAFE_TRANSFER_FROM_PATH, CONFIRM_SEND_TOKEN_PATH, + CONFIRM_SET_APPROVAL_FOR_ALL_PATH, CONFIRM_TRANSACTION_ROUTE, CONFIRM_TRANSFER_FROM_PATH, } from '../../helpers/constants/routes'; @@ -66,6 +67,30 @@ export default function ConfirmTokenTransactionSwitch({ transaction }) { /> )} /> + ( + + )} + /> Date: Tue, 12 Jul 2022 14:50:20 -0230 Subject: [PATCH 04/14] Improve confirm screen tests (#15163) * Improve confirm screen tests * Fix transaction controller unit test * Fix unit test fixtures * Fix e2e test --- .../controllers/transactions/index.test.js | 1 + shared/modules/transaction.utils.js | 38 ++--- shared/modules/transaction.utils.test.js | 89 +++++++++-- test/e2e/fixtures/special-settings/state.json | 146 ++++++++++++++++++ test/e2e/tests/send-eth.spec.js | 48 ++++++ .../confirm-transaction-base.container.js | 6 +- 6 files changed, 297 insertions(+), 31 deletions(-) create mode 100644 test/e2e/fixtures/special-settings/state.json diff --git a/app/scripts/controllers/transactions/index.test.js b/app/scripts/controllers/transactions/index.test.js index 422aaadd7..c8adf4191 100644 --- a/app/scripts/controllers/transactions/index.test.js +++ b/app/scripts/controllers/transactions/index.test.js @@ -2274,6 +2274,7 @@ describe('Transaction Controller', function () { }); it('updates editible params when type changes from simple send to token transfer', async function () { + providerResultStub.eth_getCode = '0xab'; // test update gasFees await txController.updateEditableParams('1', { data: diff --git a/shared/modules/transaction.utils.js b/shared/modules/transaction.utils.js index 1910ef2f3..688548b8e 100644 --- a/shared/modules/transaction.utils.js +++ b/shared/modules/transaction.utils.js @@ -148,33 +148,35 @@ export async function determineTransactionType(txParams, query) { log.debug('Failed to parse transaction data.', error, data); } - const tokenMethodName = [ - TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, - TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, - TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, - TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, - TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, - ].find((methodName) => isEqualCaseInsensitive(methodName, name)); - let result; - if (data && tokenMethodName) { - result = tokenMethodName; - } else if (data && !to) { - result = TRANSACTION_TYPES.DEPLOY_CONTRACT; - } - let contractCode; - if (!result) { + if (data && !to) { + result = TRANSACTION_TYPES.DEPLOY_CONTRACT; + } else { const { contractCode: resultCode, isContractAddress, } = await readAddressAsContract(query, to); contractCode = resultCode; - result = isContractAddress - ? TRANSACTION_TYPES.CONTRACT_INTERACTION - : TRANSACTION_TYPES.SIMPLE_SEND; + + if (isContractAddress) { + const tokenMethodName = [ + TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, + TRANSACTION_TYPES.TOKEN_METHOD_SET_APPROVAL_FOR_ALL, + TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, + TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, + TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM, + ].find((methodName) => isEqualCaseInsensitive(methodName, name)); + + result = + data && tokenMethodName + ? tokenMethodName + : TRANSACTION_TYPES.CONTRACT_INTERACTION; + } else { + result = TRANSACTION_TYPES.SIMPLE_SEND; + } } return { type: result, getCodeResponse: contractCode }; diff --git a/shared/modules/transaction.utils.test.js b/shared/modules/transaction.utils.test.js index fba8c7827..6998022e4 100644 --- a/shared/modules/transaction.utils.test.js +++ b/shared/modules/transaction.utils.test.js @@ -111,13 +111,23 @@ describe('Transaction.utils', function () { const genericProvider = createTestProviderTools().provider; const query = new EthQuery(genericProvider); - it('should return a simple send type when to is truthy but data is falsy', async function () { + it('should return a simple send type when to is truthy and is not a contract address', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { to: '0xabc', data: '', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.SIMPLE_SEND, @@ -125,33 +135,78 @@ describe('Transaction.utils', function () { }); }); - it('should return a token transfer type when data is for the respective method call', async function () { + it('should return a token transfer type when the recipient is a contract and data is for the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0xab', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER, - getCodeResponse: undefined, + getCodeResponse: '0xab', }); }); - it('should return a token approve type when data is for the respective method call', async function () { + it('should NOT return a token transfer type when the recipient is not a contract but the data matches the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0x', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', + data: + '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', + }, + new EthQuery(_provider), + ); + expect(result).toMatchObject({ + type: TRANSACTION_TYPES.SIMPLE_SEND, + getCodeResponse: '0x', + }); + }); + + it('should return a token approve type when when the recipient is a contract and data is for the respective method call', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: '0xab', + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + + const result = await determineTransactionType( + { + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0x095ea7b30000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C9700000000000000000000000000000000000000000000000000000000000000005', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.TOKEN_METHOD_APPROVE, - getCodeResponse: undefined, + getCodeResponse: '0xab', }); }); @@ -184,12 +239,22 @@ describe('Transaction.utils', function () { }); it('should return a simple send type with a null getCodeResponse when to is truthy and there is data and but getCode returns an error', async function () { + const _providerResultStub = { + // 1 gwei + eth_gasPrice: '0x0de0b6b3a7640000', + // by default, all accounts are external accounts (not contracts) + eth_getCode: null, + }; + const _provider = createTestProviderTools({ + scaffold: _providerResultStub, + }).provider; + const result = await determineTransactionType( { - to: '0xabc', + to: '0x9e673399f795D01116e9A8B2dD2F156705131ee9', data: '0xabd', }, - query, + new EthQuery(_provider), ); expect(result).toMatchObject({ type: TRANSACTION_TYPES.SIMPLE_SEND, diff --git a/test/e2e/fixtures/special-settings/state.json b/test/e2e/fixtures/special-settings/state.json new file mode 100644 index 000000000..6163d7621 --- /dev/null +++ b/test/e2e/fixtures/special-settings/state.json @@ -0,0 +1,146 @@ +{ + "data": { + "AppStateController": { + "mkrMigrationReminderTimestamp": null + }, + "CachedBalancesController": { + "cachedBalances": { + "4": {} + } + }, + "CurrencyController": { + "conversionDate": 1575697244.188, + "conversionRate": 149.61, + "currentCurrency": "usd", + "nativeCurrency": "ETH" + }, + "IncomingTransactionsController": { + "incomingTransactions": {}, + "incomingTxLastFetchedBlocksByNetwork": { + "goerli": null, + "kovan": null, + "mainnet": null, + "rinkeby": 5570536 + } + }, + "KeyringController": { + "vault": "{\"data\":\"s6TpYjlUNsn7ifhEFTkuDGBUM1GyOlPrim7JSjtfIxgTt8/6MiXgiR/CtFfR4dWW2xhq85/NGIBYEeWrZThGdKGarBzeIqBfLFhw9n509jprzJ0zc2Rf+9HVFGLw+xxC4xPxgCS0IIWeAJQ+XtGcHmn0UZXriXm8Ja4kdlow6SWinB7sr/WM3R0+frYs4WgllkwggDf2/Tv6VHygvLnhtzp6hIJFyTjh+l/KnyJTyZW1TkZhDaNDzX3SCOHT\",\"iv\":\"FbeHDAW5afeWNORfNJBR0Q==\",\"salt\":\"TxZ+WbCW6891C9LK/hbMAoUsSEW1E8pyGLVBU6x5KR8=\"}" + }, + "NetworkController": { + "network": "1337", + "provider": { + "nickname": "Localhost 8545", + "rpcUrl": "http://localhost:8545", + "chainId": "0x539", + "ticker": "ETH", + "type": "rpc" + } + }, + "NotificationController": { + "notifications": { + "1": { + "isShown": true + }, + "3": { + "isShown": true + }, + "5": { + "isShown": true + }, + "6": { + "isShown": true + }, + "8": { + "isShown": true + }, + "12": { + "isShown": true + } + } + }, + "OnboardingController": { + "onboardingTabs": {}, + "seedPhraseBackedUp": false + }, + "PermissionsMetadata": { + "domainMetadata": { + "metamask.github.io": { + "icon": null, + "name": "M E T A M A S K M E S H T E S T" + } + }, + "permissionsHistory": {}, + "permissionsLog": [ + { + "id": 746677923, + "method": "eth_accounts", + "methodType": "restricted", + "origin": "metamask.github.io", + "request": { + "id": 746677923, + "jsonrpc": "2.0", + "method": "eth_accounts", + "origin": "metamask.github.io", + "params": [] + }, + "requestTime": 1575697241368, + "response": { + "id": 746677923, + "jsonrpc": "2.0", + "result": [] + }, + "responseTime": 1575697241370, + "success": true + } + ] + }, + "PreferencesController": { + "accountTokens": { + "0x5cfe73b6021e818b776b421b1c4db2474086a7e1": { + "rinkeby": [], + "ropsten": [] + } + }, + "assetImages": {}, + "completedOnboarding": true, + "dismissSeedBackUpReminder": true, + "currentLocale": "en", + "featureFlags": { + "showIncomingTransactions": true, + "transactionTime": false, + "sendHexData": true + }, + "firstTimeFlowType": "create", + "forgottenPassword": false, + "frequentRpcListDetail": [], + "identities": { + "0x5cfe73b6021e818b776b421b1c4db2474086a7e1": { + "address": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1", + "name": "Account 1" + } + }, + "knownMethodData": {}, + "lostIdentities": {}, + "metaMetricsId": null, + "participateInMetaMetrics": false, + "preferences": { + "useNativeCurrencyAsPrimaryCurrency": true + }, + "selectedAddress": "0x5cfe73b6021e818b776b421b1c4db2474086a7e1", + "suggestedTokens": {}, + "tokens": [], + "useBlockie": false, + "useNonceField": false, + "usePhishDetect": true, + "useTokenDetection": true + }, + "config": {}, + "firstTimeInfo": { + "date": 1575697234195, + "version": "7.7.0" + } + }, + "meta": { + "version": 40 + } +} diff --git a/test/e2e/tests/send-eth.spec.js b/test/e2e/tests/send-eth.spec.js index e34ab5731..f14bb1bc5 100644 --- a/test/e2e/tests/send-eth.spec.js +++ b/test/e2e/tests/send-eth.spec.js @@ -92,6 +92,54 @@ describe('Send ETH from inside MetaMask using default gas', function () { }); }); +describe('Send ETH non-contract address with data that matches ERC20 transfer data signature', function () { + const ganacheOptions = { + accounts: [ + { + secretKey: + '0x7C9529A67102755B7E6102D6D950AC5D5863C98713805CEC576B945B15B71EAC', + balance: convertToHexValue(25000000000000000000), + }, + ], + }; + it('renders the correct recipient on the confirmation screen', async function () { + await withFixtures( + { + fixtures: 'special-settings', + ganacheOptions, + title: this.test.title, + }, + async ({ driver }) => { + await driver.navigate(); + await driver.fill('#password', 'correct horse battery staple'); + await driver.press('#password', driver.Key.ENTER); + + await driver.clickElement('[data-testid="eth-overview-send"]'); + + await driver.fill( + 'input[placeholder="Search, public address (0x), or ENS"]', + '0xc427D562164062a23a5cFf596A4a3208e72Acd28', + ); + + await driver.fill( + 'textarea[placeholder="Optional', + '0xa9059cbb0000000000000000000000002f318C334780961FB129D2a6c30D0763d9a5C970000000000000000000000000000000000000000000000000000000000000000a', + ); + + await driver.clickElement({ text: 'Next', tag: 'button' }); + + await driver.clickElement({ text: '0xc42...cd28' }); + + const recipientAddress = await driver.findElements({ + text: '0xc427D562164062a23a5cFf596A4a3208e72Acd28', + }); + + assert.equal(recipientAddress.length, 1); + }, + ); + }); +}); + /* eslint-disable-next-line mocha/max-top-level-suites */ describe('Send ETH from inside MetaMask using advanced gas modal', function () { const ganacheOptions = { diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index e36049c80..7321d2793 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -54,6 +54,7 @@ import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils'; import { getGasLoadingAnimationIsShowing } from '../../ducks/app/app'; import { isLegacyTransaction } from '../../helpers/utils/transactions.util'; import { CUSTOM_GAS_ESTIMATE } from '../../../shared/constants/gas'; +import { TRANSACTION_TYPES } from '../../../shared/constants/transaction'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; import { getTokenAddressParam } from '../../helpers/utils/token-util'; import ConfirmTransactionBase from './confirm-transaction-base.component'; @@ -112,7 +113,10 @@ const mapStateToProps = (state, ownProps) => { const { balance } = accounts[fromAddress]; const { name: fromName } = identities[fromAddress]; - const toAddress = propsToAddress || tokenToAddress || txParamsToAddress; + let toAddress = txParamsToAddress; + if (type !== TRANSACTION_TYPES.SIMPLE_SEND) { + toAddress = propsToAddress || tokenToAddress || txParamsToAddress; + } const tokenList = getTokenList(state); const useTokenDetection = getUseTokenDetection(state); From c63714c4f2a1903e4677871f3880bfa1658a4db3 Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Wed, 13 Jul 2022 19:45:38 -0230 Subject: [PATCH 05/14] Show users a warning when they are sending directly to a token contract (#13588) * Fix warning dialog when sending tokens to a known token contract address Fixing after rebase Covering missed cases Rebased and ran yarn setup Rebased Fix checkContractAddress condition Lint fix Applied requested changes Fix unit tests Applying requested changes Applied requested changes Refactor and update Lint fix Use V2 of ActionableMessage component Adding Learn More Link Updating warning copy Addressing review feedback Fix up copy changes Simplify validation of pasted addresses Improve detection of whether this is a token contract Refactor to leave updateRecipient unchanged, and to prevent the double calling of update recipient Update tests fix * Fix unit tests * Fix e2e tests * Ensure next button is disabled while recipient type is loading * Add optional chaining and a fallback to getRecipientWarningAcknowledgement * Fix lint * Don't reset recipient warning on asset change, because we should show recipient warnings regardless of asset * Update unit tests * Update unit tests Co-authored-by: Filip Sekulic --- app/_locales/en/messages.json | 4 + test/e2e/tests/send-hex-address.spec.js | 2 + ui/ducks/send/send.js | 151 ++++++++++---- ui/ducks/send/send.test.js | 188 ++++++++++++------ ui/helpers/constants/common.js | 3 + ui/helpers/utils/token-util.js | 2 +- .../add-recipient/add-recipient.component.js | 2 + .../send-content/send-content.component.js | 49 ++++- .../send-content.component.test.js | 28 ++- .../send-content/send-content.container.js | 17 +- ui/pages/send/send.js | 4 +- ui/pages/send/send.scss | 9 + ui/pages/send/send.test.js | 19 ++ 13 files changed, 375 insertions(+), 103 deletions(-) diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 78bb505b8..4e3a7ec46 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -2891,6 +2891,10 @@ "message": "Sending $1", "description": "$1 represents the native currency symbol for the current network (e.g. ETH or BNB)" }, + "sendingToTokenContractWarning": { + "message": "Warning: you are about to send to a token contract which could result in a loss of funds. $1", + "description": "$1 is a clickable link with text defined by the 'learnMoreUpperCase' key. The link will open to a support article regarding the known contract address warning" + }, "setAdvancedPrivacySettings": { "message": "Set advanced privacy settings" }, diff --git a/test/e2e/tests/send-hex-address.spec.js b/test/e2e/tests/send-hex-address.spec.js index 52ff459a3..ed89901db 100644 --- a/test/e2e/tests/send-hex-address.spec.js +++ b/test/e2e/tests/send-hex-address.spec.js @@ -198,6 +198,7 @@ describe('Send ERC20 to a 40 character hexadecimal address', function () { ); return sendDialogMsgs.length === 1; }, 10000); + await driver.delay(2000); await driver.clickElement({ text: 'Next', tag: 'button' }); // Confirm transaction @@ -296,6 +297,7 @@ describe('Send ERC20 to a 40 character hexadecimal address', function () { ); return sendDialogMsgs.length === 1; }, 10000); + await driver.delay(2000); await driver.clickElement({ text: 'Next', tag: 'button' }); // Confirm transaction diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index f27a9d6a5..c89d23865 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -69,6 +69,7 @@ import { calcTokenAmount, getTokenAddressParam, getTokenValueParam, + getTokenMetadata, } from '../../helpers/utils/token-util'; import { checkExistingAddresses, @@ -382,6 +383,7 @@ export const draftTransactionInitialState = { error: null, nickname: '', warning: null, + recipientWarningAcknowledged: false, }, status: SEND_STATUSES.VALID, transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, @@ -952,14 +954,6 @@ const slice = createSlice({ // are no longer valid when sending native currency. draftTransaction.recipient.error = null; } - - if ( - draftTransaction.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING - ) { - // Warning related to sending tokens to a known contract address - // are no longer valid when sending native currency. - draftTransaction.recipient.warning = null; - } } // if amount mode is MAX update amount to max of new asset, otherwise set // to zero. This will revalidate the send amount field. @@ -1154,6 +1148,26 @@ const slice = createSlice({ state.recipientInput = ''; state.recipientMode = action.payload; }, + + updateRecipientWarning: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.warning = action.payload; + }, + + updateDraftTransactionStatus: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.status = action.payload; + }, + + acknowledgeRecipientWarning: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.recipientWarningAcknowledged = true; + slice.caseReducers.validateSendState(state); + }, + /** * Updates the value of the recipientInput key with what the user has * typed into the recipient input field in the UI. @@ -1316,10 +1330,13 @@ const slice = createSlice({ draftTransaction.recipient.error = null; draftTransaction.recipient.warning = null; } else { - const isSendingToken = - draftTransaction.asset.type === ASSET_TYPES.TOKEN || - draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE; - const { chainId, tokens, tokenAddressList } = action.payload; + const { + chainId, + tokens, + tokenAddressList, + isProbablyAnAssetContract, + } = action.payload; + if ( isBurnAddress(state.recipientInput) || (!isValidHexAddress(state.recipientInput, { @@ -1331,10 +1348,9 @@ const slice = createSlice({ ? INVALID_RECIPIENT_ADDRESS_ERROR : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; } else if ( - isSendingToken && isOriginContractAddress( state.recipientInput, - draftTransaction.asset.details.address, + draftTransaction.asset?.details?.address, ) ) { draftTransaction.recipient.error = CONTRACT_ADDRESS_ERROR; @@ -1342,12 +1358,12 @@ const slice = createSlice({ draftTransaction.recipient.error = null; } if ( - isSendingToken && - isValidHexAddress(state.recipientInput) && - (tokenAddressList.find((address) => - isEqualCaseInsensitive(address, state.recipientInput), - ) || - checkExistingAddresses(state.recipientInput, tokens)) + (isValidHexAddress(state.recipientInput) && + (tokenAddressList.find((address) => + isEqualCaseInsensitive(address, state.recipientInput), + ) || + checkExistingAddresses(state.recipientInput, tokens))) || + isProbablyAnAssetContract ) { draftTransaction.recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; } else { @@ -1355,6 +1371,7 @@ const slice = createSlice({ } } } + slice.caseReducers.validateSendState(state); }, /** * Checks if the draftTransaction is currently valid. The following list of @@ -1392,6 +1409,12 @@ const slice = createSlice({ ): draftTransaction.status = SEND_STATUSES.INVALID; break; + case draftTransaction.recipient.warning === 'loading': + case draftTransaction.recipient.warning === + KNOWN_RECIPIENT_ADDRESS_WARNING && + draftTransaction.recipient.recipientWarningAcknowledged === false: + draftTransaction.status = SEND_STATUSES.INVALID; + break; default: draftTransaction.status = SEND_STATUSES.VALID; } @@ -1589,9 +1612,16 @@ const { validateRecipientUserInput, updateRecipientSearchMode, addHistoryEntry, + acknowledgeRecipientWarning, } = actions; -export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; +export { + useDefaultGas, + useCustomGas, + updateGasLimit, + addHistoryEntry, + acknowledgeRecipientWarning, +}; // Action Creators @@ -1601,14 +1631,18 @@ export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; * passing in both the dispatch method and the payload to dispatch, which makes * it only applicable for use within action creators. */ -const debouncedValidateRecipientUserInput = debounce((dispatch, payload) => { - dispatch( - addHistoryEntry( - `sendFlow - user typed ${payload.userInput} into recipient input field`, - ), - ); - dispatch(validateRecipientUserInput(payload)); -}, 300); +const debouncedValidateRecipientUserInput = debounce( + (dispatch, payload, resolve) => { + dispatch( + addHistoryEntry( + `sendFlow - user typed ${payload.userInput} into recipient input field`, + ), + ); + dispatch(validateRecipientUserInput(payload)); + resolve(); + }, + 300, +); /** * Begins a new draft transaction, derived from the txParams of an existing @@ -1799,18 +1833,55 @@ export function updateRecipient({ address, nickname }) { */ export function updateRecipientUserInput(userInput) { return async (dispatch, getState) => { + dispatch(actions.updateRecipientWarning('loading')); + dispatch(actions.updateDraftTransactionStatus(SEND_STATUSES.INVALID)); await dispatch(actions.updateRecipientUserInput(userInput)); const state = getState(); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + const sendingAddress = + draftTransaction.fromAccount?.address ?? + state[name].selectedAccount.address ?? + getSelectedAddress(state); const chainId = getCurrentChainId(state); const tokens = getTokens(state); const useTokenDetection = getUseTokenDetection(state); - const tokenAddressList = Object.keys(getTokenList(state)); - debouncedValidateRecipientUserInput(dispatch, { - userInput, - chainId, - tokens, - useTokenDetection, - tokenAddressList, + const tokenMap = getTokenList(state); + const tokenAddressList = Object.keys(tokenMap); + + const inputIsValidHexAddress = isValidHexAddress(userInput); + let isProbablyAnAssetContract = false; + if (inputIsValidHexAddress) { + const { symbol, decimals } = getTokenMetadata(userInput, tokenMap) || {}; + + isProbablyAnAssetContract = symbol && decimals !== undefined; + + if (!isProbablyAnAssetContract) { + try { + const { standard } = await getTokenStandardAndDetails( + userInput, + sendingAddress, + ); + isProbablyAnAssetContract = Boolean(standard); + } catch (e) { + console.log(e); + } + } + } + + return new Promise((resolve) => { + debouncedValidateRecipientUserInput( + dispatch, + { + userInput, + chainId, + tokens, + useTokenDetection, + tokenAddressList, + isProbablyAnAssetContract, + }, + resolve, + ); }); }; } @@ -2008,6 +2079,7 @@ export function updateSendHexData(hexData) { await dispatch( addHistoryEntry(`sendFlow - user added custom hexData ${hexData}`), ); + await dispatch(actions.updateUserInputHexData(hexData)); const state = getState(); const draftTransaction = @@ -2486,6 +2558,13 @@ export function getRecipientUserInput(state) { return state[name].recipientInput; } +export function getRecipientWarningAcknowledgement(state) { + return ( + getCurrentDraftTransaction(state).recipient?.recipientWarningAcknowledged ?? + false + ); +} + // Overall validity and stage selectors /** diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 1b7ef3608..339770198 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -87,6 +87,11 @@ jest.mock('./send', () => { }; }); +jest.mock('lodash', () => ({ + ...jest.requireActual('lodash'), + debounce: (fn) => fn, +})); + setBackgroundConnection({ addPollingTokenToAppState: jest.fn(), addUnapprovedTransaction: jest.fn((_w, _x, _y, _z, cb) => { @@ -495,33 +500,6 @@ describe('Send Slice', () => { expect(draftTransaction.recipient.error).toBeNull(); }); - it('should nullify old known address error when asset types is not TOKEN', () => { - const recipientErrorState = getInitialSendStateWithExistingTxState({ - recipient: { - warning: KNOWN_RECIPIENT_ADDRESS_WARNING, - }, - asset: { - type: ASSET_TYPES.TOKEN, - }, - }); - - const action = { - type: 'send/updateAsset', - payload: { - type: 'New Type', - }, - }; - - const result = sendReducer(recipientErrorState, action); - - const draftTransaction = getTestUUIDTx(result); - - expect(draftTransaction.recipient.warning).not.toStrictEqual( - KNOWN_RECIPIENT_ADDRESS_WARNING, - ); - expect(draftTransaction.recipient.warning).toBeNull(); - }); - it('should update asset type and details to TOKEN payload', () => { const action = { type: 'send/updateAsset', @@ -626,6 +604,12 @@ describe('Send Slice', () => { error: 'someError', warning: 'someWarning', }, + amount: {}, + gas: { + gasLimit: '0x0', + minimumGasLimit: '0x0', + }, + asset: {}, }), recipientInput: '', recipientMode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, @@ -746,6 +730,82 @@ describe('Send Slice', () => { 'contractAddressError', ); }); + + it('should set a warning when sending to a token address in the token address list', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [], + useTokenDetection: true, + tokenAddressList: ['0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc'], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); + }); + + it('should set a warning when sending to a token address in the token list', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [{ address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc' }], + useTokenDetection: true, + tokenAddressList: [], + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); + }); + + it('should set a warning when sending to an address that is probably a token contract', () => { + const tokenAssetTypeState = { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }; + + const action = { + type: 'send/validateRecipientUserInput', + payload: { + chainId: '0x4', + tokens: [{ address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }], + useTokenDetection: true, + tokenAddressList: ['0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'], + isProbablyAnAssetContract: true, + }, + }; + + const result = sendReducer(tokenAssetTypeState, action); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, + ); + }); }); describe('updateRecipientSearchMode', () => { @@ -1643,11 +1703,10 @@ describe('Send Slice', () => { }, }, }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }; it('should create actions for updateRecipientUserInput and checks debounce for validation', async () => { - const clock = sinon.useFakeTimers(); - const store = mockStore(updateRecipientUserInputState); const newUserRecipientInput = 'newUserRecipientInput'; @@ -1655,29 +1714,35 @@ describe('Send Slice', () => { const actionResult = store.getActions(); - expect(actionResult).toHaveLength(1); + expect(actionResult).toHaveLength(5); + expect(actionResult[0].type).toStrictEqual( + 'send/updateRecipientWarning', + ); + expect(actionResult[0].payload).toStrictEqual('loading'); + + expect(actionResult[1].type).toStrictEqual( + 'send/updateDraftTransactionStatus', + ); + + expect(actionResult[2].type).toStrictEqual( 'send/updateRecipientUserInput', ); - expect(actionResult[0].payload).toStrictEqual(newUserRecipientInput); + expect(actionResult[2].payload).toStrictEqual(newUserRecipientInput); - clock.tick(300); // debounce - - const actionResultAfterDebounce = store.getActions(); - expect(actionResultAfterDebounce).toHaveLength(3); - - expect(actionResultAfterDebounce[1]).toMatchObject({ + expect(actionResult[3]).toMatchObject({ type: 'send/addHistoryEntry', payload: `sendFlow - user typed ${newUserRecipientInput} into recipient input field`, }); - expect(actionResultAfterDebounce[2].type).toStrictEqual( + expect(actionResult[4].type).toStrictEqual( 'send/validateRecipientUserInput', ); - expect(actionResultAfterDebounce[2].payload).toStrictEqual({ + expect(actionResult[4].payload).toStrictEqual({ chainId: '', tokens: [], useTokenDetection: true, + isProbablyAnAssetContract: false, userInput: newUserRecipientInput, tokenAddressList: ['0x514910771af9ca656af840dff83e8264ecf986ca'], }); @@ -1934,21 +1999,7 @@ describe('Send Slice', () => { }, }, }, - send: { - asset: { - type: '', - }, - recipient: { - address: 'Address', - nickname: 'NickName', - }, - gas: { - gasPrice: '0x1', - }, - amount: { - value: '0x1', - }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }; const store = mockStore(updateRecipientState); @@ -1956,24 +2007,36 @@ describe('Send Slice', () => { await store.dispatch(resetRecipientInput()); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(7); + expect(actionResult).toHaveLength(11); expect(actionResult[0]).toMatchObject({ type: 'send/addHistoryEntry', payload: 'sendFlow - user cleared recipient input', }); expect(actionResult[1].type).toStrictEqual( + 'send/updateRecipientWarning', + ); + expect(actionResult[2].type).toStrictEqual( + 'send/updateDraftTransactionStatus', + ); + + expect(actionResult[3].type).toStrictEqual( 'send/updateRecipientUserInput', ); - expect(actionResult[1].payload).toStrictEqual(''); - expect(actionResult[2].type).toStrictEqual('send/updateRecipient'); - expect(actionResult[3].type).toStrictEqual( + expect(actionResult[4].payload).toStrictEqual( + 'sendFlow - user typed into recipient input field', + ); + expect(actionResult[5].type).toStrictEqual( + 'send/validateRecipientUserInput', + ); + expect(actionResult[6].type).toStrictEqual('send/updateRecipient'); + expect(actionResult[7].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[4].type).toStrictEqual( + expect(actionResult[8].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); - expect(actionResult[5].type).toStrictEqual('ENS/resetEnsResolution'); - expect(actionResult[6].type).toStrictEqual( + expect(actionResult[9].type).toStrictEqual('ENS/resetEnsResolution'); + expect(actionResult[10].type).toStrictEqual( 'send/validateRecipientUserInput', ); }); @@ -2326,6 +2389,7 @@ describe('Send Slice', () => { error: null, nickname: '', warning: null, + recipientWarningAcknowledged: false, }, status: SEND_STATUSES.VALID, transactionType: '0x0', @@ -2468,6 +2532,7 @@ describe('Send Slice', () => { error: null, nickname: '', warning: null, + recipientWarningAcknowledged: false, }, status: SEND_STATUSES.VALID, transactionType: '0x0', @@ -2653,6 +2718,7 @@ describe('Send Slice', () => { error: null, warning: null, nickname: '', + recipientWarningAcknowledged: false, }, status: SEND_STATUSES.VALID, transactionType: '0x0', diff --git a/ui/helpers/constants/common.js b/ui/helpers/constants/common.js index 2663c2108..7e980f822 100644 --- a/ui/helpers/constants/common.js +++ b/ui/helpers/constants/common.js @@ -47,6 +47,8 @@ export const GAS_ESTIMATE_TYPES = { let _supportLink = 'https://support.metamask.io'; let _supportRequestLink = 'https://metamask.zendesk.com/hc/en-us/requests/new'; +const _contractAddressLink = + 'https://metamask.zendesk.com/hc/en-us/articles/360020028092-What-is-the-known-contract-address-warning-'; ///: BEGIN:ONLY_INCLUDE_IN(flask) _supportLink = 'https://metamask-flask.zendesk.com/hc'; @@ -56,3 +58,4 @@ _supportRequestLink = export const SUPPORT_LINK = _supportLink; export const SUPPORT_REQUEST_LINK = _supportRequestLink; +export const CONTRACT_ADDRESS_LINK = _contractAddressLink; diff --git a/ui/helpers/utils/token-util.js b/ui/helpers/utils/token-util.js index da399c33f..784ceb772 100644 --- a/ui/helpers/utils/token-util.js +++ b/ui/helpers/utils/token-util.js @@ -43,7 +43,7 @@ async function getDecimalsFromContract(tokenAddress) { } } -function getTokenMetadata(tokenAddress, tokenList) { +export function getTokenMetadata(tokenAddress, tokenList) { const casedTokenList = Object.keys(tokenList).reduce((acc, base) => { return { ...acc, diff --git a/ui/pages/send/send-content/add-recipient/add-recipient.component.js b/ui/pages/send/send-content/add-recipient/add-recipient.component.js index fa2dbbfef..b0f791560 100644 --- a/ui/pages/send/send-content/add-recipient/add-recipient.component.js +++ b/ui/pages/send/send-content/add-recipient/add-recipient.component.js @@ -32,6 +32,7 @@ export default class AddRecipient extends Component { error: PropTypes.string, warning: PropTypes.string, }), + updateRecipientUserInput: PropTypes.func, }; constructor(props) { @@ -70,6 +71,7 @@ export default class AddRecipient extends Component { `sendFlow - User clicked recipient from ${type}. address: ${address}, nickname ${nickname}`, ); this.props.updateRecipient({ address, nickname }); + this.props.updateRecipientUserInput(address); }; searchForContacts = () => { diff --git a/ui/pages/send/send-content/send-content.component.js b/ui/pages/send/send-content/send-content.component.js index 3b54b4a78..a856624d1 100644 --- a/ui/pages/send/send-content/send-content.component.js +++ b/ui/pages/send/send-content/send-content.component.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import PageContainerContent from '../../../components/ui/page-container/page-container-content.component'; import Dialog from '../../../components/ui/dialog'; +import ActionableMessage from '../../../components/ui/actionable-message'; import NicknamePopovers from '../../../components/app/modals/nickname-popovers'; import { ETH_GAS_PRICE_FETCH_WARNING_KEY, @@ -10,6 +11,7 @@ import { INSUFFICIENT_FUNDS_FOR_GAS_ERROR_KEY, } from '../../../helpers/constants/error-keys'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; +import { CONTRACT_ADDRESS_LINK } from '../../../helpers/constants/common'; import SendAmountRow from './send-amount-row'; import SendHexDataRow from './send-hex-data-row'; import SendAssetRow from './send-asset-row'; @@ -38,6 +40,9 @@ export default class SendContent extends Component { asset: PropTypes.object, to: PropTypes.string, assetError: PropTypes.string, + recipient: PropTypes.object, + acknowledgeRecipientWarning: PropTypes.func, + recipientWarningAcknowledged: PropTypes.bool, }; render() { @@ -51,6 +56,8 @@ export default class SendContent extends Component { getIsBalanceInsufficient, asset, assetError, + recipient, + recipientWarningAcknowledged, } = this.props; let gasError; @@ -66,6 +73,10 @@ export default class SendContent extends Component { asset.type !== ASSET_TYPES.TOKEN && asset.type !== ASSET_TYPES.COLLECTIBLE; + const showKnownRecipientWarning = + recipient.warning === 'knownAddressRecipient'; + const hideAddContactDialog = recipient.warning === 'loading'; + return (
@@ -76,7 +87,12 @@ export default class SendContent extends Component { : null} {error ? this.renderError(error) : null} {warning ? this.renderWarning() : null} - {this.maybeRenderAddContact()} + {showKnownRecipientWarning && !recipientWarningAcknowledged + ? this.renderRecipientWarning() + : null} + {showKnownRecipientWarning || hideAddContactDialog + ? null + : this.maybeRenderAddContact()} {networkOrAccountNotSupports1559 ? : null} @@ -104,6 +120,7 @@ export default class SendContent extends Component { > {t('newAccountDetectedDialogMessage')} + {showNicknamePopovers ? ( this.setState({ showNicknamePopovers: false })} @@ -124,6 +141,36 @@ export default class SendContent extends Component { ); } + renderRecipientWarning() { + const { acknowledgeRecipientWarning } = this.props; + const { t } = this.context; + return ( +
+ + {t('learnMoreUpperCase')} + , + ])} + roundedButtons + /> +
+ ); + } + renderError(error) { const { t } = this.context; return ( diff --git a/ui/pages/send/send-content/send-content.component.test.js b/ui/pages/send/send-content/send-content.component.test.js index 7b06b50e3..ec8ed3ecf 100644 --- a/ui/pages/send/send-content/send-content.component.test.js +++ b/ui/pages/send/send-content/send-content.component.test.js @@ -16,6 +16,32 @@ describe('SendContent Component', () => { gasIsExcessive: false, networkAndAccountSupports1559: true, asset: { type: 'NATIVE' }, + recipient: { + mode: 'CONTACT_LIST', + userInput: '0x31A2764925BD47CCBd57b2F277702dB46e9C5F66', + address: '0x31A2764925BD47CCBd57b2F277702dB46e9C5F66', + nickname: 'John Doe', + error: null, + warning: null, + }, + tokenAddressList: { + '0x32e6c34cd57087abbd59b5a4aecc4cb495924356': { + name: 'BitBase', + symbol: 'BTBS', + decimals: 18, + address: '0x32E6C34Cd57087aBBD59B5A4AECC4cB495924356', + iconUrl: 'BTBS.svg', + occurrences: null, + }, + '0x3fa400483487a489ec9b1db29c4129063eec4654': { + name: 'Cryptokek.com', + symbol: 'KEK', + decimals: 18, + address: '0x3fa400483487A489EC9b1dB29C4129063EEC4654', + iconUrl: 'cryptokek.svg', + occurrences: null, + }, + }, }; beforeEach(() => { @@ -150,7 +176,7 @@ describe('SendContent Component', () => { true, ); expect( - PageContainerContentChild.childAt(1).find( + PageContainerContentChild.childAt(2).find( 'send-v2__asset-dropdown__single-asset', ), ).toHaveLength(0); diff --git a/ui/pages/send/send-content/send-content.container.js b/ui/pages/send/send-content/send-content.container.js index d3e508e9f..53fca7530 100644 --- a/ui/pages/send/send-content/send-content.container.js +++ b/ui/pages/send/send-content/send-content.container.js @@ -11,6 +11,9 @@ import { getSendTo, getSendAsset, getAssetError, + getRecipient, + acknowledgeRecipientWarning, + getRecipientWarningAcknowledgement, } from '../../../ducks/send'; import SendContent from './send-content.component'; @@ -18,6 +21,10 @@ import SendContent from './send-content.component'; function mapStateToProps(state) { const ownedAccounts = accountsWithSendEtherInfoSelector(state); const to = getSendTo(state); + const recipient = getRecipient(state); + const recipientWarningAcknowledged = getRecipientWarningAcknowledgement( + state, + ); return { isOwnedAccount: Boolean( ownedAccounts.find( @@ -34,7 +41,15 @@ function mapStateToProps(state) { getIsBalanceInsufficient: getIsBalanceInsufficient(state), asset: getSendAsset(state), assetError: getAssetError(state), + recipient, + recipientWarningAcknowledged, }; } -export default connect(mapStateToProps)(SendContent); +function mapDispatchToProps(dispatch) { + return { + acknowledgeRecipientWarning: () => dispatch(acknowledgeRecipientWarning()), + }; +} + +export default connect(mapStateToProps, mapDispatchToProps)(SendContent); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js index 2c17303b4..060dbfee8 100644 --- a/ui/pages/send/send.js +++ b/ui/pages/send/send.js @@ -91,10 +91,11 @@ export default function SendTransactionScreen() { userInput={userInput} className="send__to-row" onChange={(address) => dispatch(updateRecipientUserInput(address))} - onValidAddressTyped={(address) => { + onValidAddressTyped={async (address) => { dispatch( addHistoryEntry(`sendFlow - Valid address typed ${address}`), ); + await dispatch(updateRecipientUserInput(address)); dispatch(updateRecipient({ address, nickname: '' })); }} internalSearch={isUsingMyAccountsForRecipientSearch} @@ -106,7 +107,6 @@ export default function SendTransactionScreen() { `sendFlow - User pasted ${text} into address field`, ), ); - return dispatch(updateRecipient({ address: text, nickname: '' })); }} onReset={() => dispatch(resetRecipientInput())} scanQrCode={() => { diff --git a/ui/pages/send/send.scss b/ui/pages/send/send.scss index 8d45dfcb7..2735ba89a 100644 --- a/ui/pages/send/send.scss +++ b/ui/pages/send/send.scss @@ -35,6 +35,15 @@ margin: 1rem; } + &__warning-container { + padding-left: 16px; + padding-right: 16px; + + &__link { + color: var(--primary-1); + } + } + &__to-row { margin: 0; padding: 0.5rem; diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js index 9df773701..af9a21323 100644 --- a/ui/pages/send/send.test.js +++ b/ui/pages/send/send.test.js @@ -80,6 +80,25 @@ const baseStore = { '0x0': { balance: '0x0', address: '0x0' }, }, identities: { '0x0': { address: '0x0' } }, + tokenAddress: '0x32e6c34cd57087abbd59b5a4aecc4cb495924356', + tokenList: { + '0x32e6c34cd57087abbd59b5a4aecc4cb495924356': { + name: 'BitBase', + symbol: 'BTBS', + decimals: 18, + address: '0x32E6C34Cd57087aBBD59B5A4AECC4cB495924356', + iconUrl: 'BTBS.svg', + occurrences: null, + }, + '0x3fa400483487a489ec9b1db29c4129063eec4654': { + name: 'Cryptokek.com', + symbol: 'KEK', + decimals: 18, + address: '0x3fa400483487A489EC9b1dB29C4129063EEC4654', + iconUrl: 'cryptokek.svg', + occurrences: null, + }, + }, }, appState: { sendInputCurrencySwitched: false, From de0f836a278a5b14d9a13a2eb21e146e160e0211 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 14 Jul 2022 13:22:12 -0500 Subject: [PATCH 06/14] small approve confirmation ui fixes (#15239) --- .../confirm-approve-content.component.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index cb831bbd3..b6c82a106 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -102,7 +102,7 @@ export default class ConfirmApproveContent extends Component { > {showHeader && (
- {!supportsEIP1559V2 && ( + {supportsEIP1559V2 && title === t('transactionFee') ? null : ( <>
{symbol} @@ -313,7 +313,7 @@ export default class ConfirmApproveContent extends Component {
{isSetApproveForAll && setApproveForAllArg !== undefined ? (
- {t('parameters')}: {setApproveForAllArg} + {`${t('parameters')}: ${setApproveForAllArg}`}
) : null}
From 1d342252c54261ea612ed0439b2b9fa0287bab99 Mon Sep 17 00:00:00 2001 From: seaona <54408225+seaona@users.noreply.github.com> Date: Tue, 19 Jul 2022 12:27:11 +0200 Subject: [PATCH 07/14] Changelog 10.18.0 (#15207) * Changelog 10.18.0 * Remove feature flag commits for popular networks * Remove commit Bump design tokens as has no effect on UI --- CHANGELOG.md | 40 ++++++++++------------------------------ 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7ae5ae4a..e4536d929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,37 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ## [10.18.0] -### Uncategorized -- Auto-generated Changelog -- Merge remote-tracking branch 'origin/develop' into master-sync -- Merge remote-tracking branch 'origin/develop' into master-sync -- Update CHANGELOG.md -- Merge remote-tracking branch 'origin/develop' into master-sync -- Master sync following v10.17.0 ([#15126](https://github.com/MetaMask/metamask-extension/pull/15126)) -- Disable Seedphrase import button after switching seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) -- Snaps E2E Test Upgrades for the new test-snaps UI ([#14980](https://github.com/MetaMask/metamask-extension/pull/14980)) -- The Japanese word for sign is "署名" ([#15078](https://github.com/MetaMask/metamask-extension/pull/15078)) -- Add feature flag to prevent add popular networks from being available on prod ([#15117](https://github.com/MetaMask/metamask-extension/pull/15117)) -- Bump minimum Node.js version to 16 ([#15131](https://github.com/MetaMask/metamask-extension/pull/15131)) +### Added +- Add setApprovalForAll confirmation view so granted permissions are displayed in a digested manner, instead of a simple contract interaction([#15010](https://github.com/MetaMask/metamask-extension/pull/15010)) + +### Changed - Update Optimism ChainID from Kovan to Goerli ([#15119](https://github.com/MetaMask/metamask-extension/pull/15119)) -- fix Chinese translation ([#14994](https://github.com/MetaMask/metamask-extension/pull/14994)) -- State logs e2e ([#15123](https://github.com/MetaMask/metamask-extension/pull/15123)) -- Send to hex-prefixed address e2e tests ([#15111](https://github.com/MetaMask/metamask-extension/pull/15111)) -- Merge remote-tracking branch 'origin/develop' into master-sync -- Update wallet-overview.js ([#15125](https://github.com/MetaMask/metamask-extension/pull/15125)) -- Remove global transaction state from send flow ([#14777](https://github.com/MetaMask/metamask-extension/pull/14777)) -- Ensure native asset icon does not display incorrectly when switching networks and balance is loading ([#15116](https://github.com/MetaMask/metamask-extension/pull/15116)) -- Master Sync PR following v10.16.2 ([#15108](https://github.com/MetaMask/metamask-extension/pull/15108)) -- Adding popular custom network integration ([#14557](https://github.com/MetaMask/metamask-extension/pull/14557)) -- Add fallback image/card for NFTs when image was not fetched correctly or does not exist ([#15034](https://github.com/MetaMask/metamask-extension/pull/15034)) -- Merge remote-tracking branch 'origin/develop' into master-sync -- Bump @metamask/design-tokens from 1.6.5 to 1.7.0 ([#15017](https://github.com/MetaMask/metamask-extension/pull/15017)) -- Master Sync after v10.16.1 ([#15079](https://github.com/MetaMask/metamask-extension/pull/15079)) -- Update CHANGELOG.md -- Merge remote-tracking branch 'origin/develop' into master-sync -- Remove change from cb77f94 that breaks ens inputs in send flow ([#15069](https://github.com/MetaMask/metamask-extension/pull/15069)) -- Fix tokenIds larger than MAX_SAFE_INTEGER converted to scientific notation and failing to import ([#15016](https://github.com/MetaMask/metamask-extension/pull/15016)) -- Chromedriver v103 ([#15015](https://github.com/MetaMask/metamask-extension/pull/15015)) + +### Fixed +- Fix one of the possible causes for "Sending to a random cached address", by removing the global transaction state from the Send flow ([#14777](https://github.com/MetaMask/metamask-extension/pull/14777)) +- Fix Chinese translation for the message of Importing repeated tokens ([#14994](https://github.com/MetaMask/metamask-extension/pull/14994)) +- Fix Japanese translation for the word Sign ([#15078](https://github.com/MetaMask/metamask-extension/pull/15078)) +- Fix partially the error "Seedphrase is invalid" by disabling Seedphrase Import button after switching the Seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) ## [10.17.0] ### Added From 11abdddaa2596b5fc523f606556aa73fafdf2cc2 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Wed, 20 Jul 2022 13:42:57 -0500 Subject: [PATCH 08/14] show asset name instead of symbol for setApprovalForAll calls on NFT contracts where possible (#15296) --- .../confirm-approve-content.component.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index b6c82a106..2211ae82a 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -453,6 +453,7 @@ export default class ConfirmApproveContent extends Component { chainId, assetStandard, tokenSymbol, + isSetApproveForAll, } = this.props; const { t } = this.context; let titleTokenDescription = t('token'); @@ -479,7 +480,10 @@ export default class ConfirmApproveContent extends Component { titleTokenDescription = unknownTokenLink; } - if (assetStandard === ERC20 || (tokenSymbol && !tokenId)) { + if ( + assetStandard === ERC20 || + (tokenSymbol && !tokenId && !isSetApproveForAll) + ) { titleTokenDescription = tokenSymbol; } else if ( assetStandard === ERC721 || @@ -488,9 +492,9 @@ export default class ConfirmApproveContent extends Component { (assetName && tokenId) || (tokenSymbol && tokenId) ) { - const tokenIdWrapped = tokenId ? ` (#${tokenId})` : null; + const tokenIdWrapped = tokenId ? ` (#${tokenId})` : ''; if (assetName || tokenSymbol) { - titleTokenDescription = `${assetName ?? tokenSymbol} ${tokenIdWrapped}`; + titleTokenDescription = `${assetName ?? tokenSymbol}${tokenIdWrapped}`; } else { const unknownNFTBlockExplorerLink = getTokenTrackerLink( tokenAddress, From 930f5e85583c1534a191b5ffccf643bf242a925a Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 20 Jul 2022 22:54:50 +0200 Subject: [PATCH 09/14] Fix stringified object on NFT approve screen (#15287) --- .../confirm-approve-content.component.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index 2211ae82a..1e0e1668e 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -204,7 +204,7 @@ export default class ConfirmApproveContent extends Component {
{isSetApproveForAll - ? `${t('allOfYour', [titleTokenDescription])} ` + ? t('allOfYour', [titleTokenDescription]) : titleTokenDescription}
From 11374ec4d72318590767ffb4cb202deaa27d6abe Mon Sep 17 00:00:00 2001 From: Dan J Miller Date: Wed, 20 Jul 2022 20:33:23 -0230 Subject: [PATCH 10/14] Ensure that editing a tx from a transfer to a simple send resets data and updates type (#15248) * Ensure that editing a transaction from a transfer to a simple send properly resets data and updates type * Handle case where there are no unapproved txes * Improve comment in updateSendAsset * Remove unnecessary code in send transaction edit function * Fix * Ensure hex data is properly reset when changing from a safe transfer from tx to native send --- app/scripts/controllers/transactions/index.js | 5 +++- ui/ducks/send/send.js | 24 +++++++++++++++++-- ui/store/actions.js | 2 +- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/scripts/controllers/transactions/index.js b/app/scripts/controllers/transactions/index.js index 0818fc894..87d56c714 100644 --- a/app/scripts/controllers/transactions/index.js +++ b/app/scripts/controllers/transactions/index.js @@ -462,7 +462,10 @@ export default class TransactionController extends EventEmitter { }; // only update what is defined - editableParams.txParams = pickBy(editableParams.txParams); + editableParams.txParams = pickBy( + editableParams.txParams, + (prop) => prop !== undefined, + ); // update transaction type in case it has changes const transactionBeforeEdit = this._getTransaction(txId); diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index c89d23865..725d59bd1 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -941,6 +941,7 @@ const slice = createSlice({ draftTransaction.asset.type = action.payload.type; draftTransaction.asset.balance = action.payload.balance; draftTransaction.asset.error = action.payload.error; + if ( draftTransaction.asset.type === ASSET_TYPES.TOKEN || draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE @@ -1961,6 +1962,9 @@ export function updateSendAsset( getSelectedAddress(state); const account = getTargetAccount(state, sendingAddress); if (type === ASSET_TYPES.NATIVE) { + const unapprovedTxs = getUnapprovedTxs(state); + const unapprovedTx = unapprovedTxs?.[draftTransaction.id]; + await dispatch( addHistoryEntry( `sendFlow - user set asset of type ${ @@ -1976,6 +1980,20 @@ export function updateSendAsset( error: null, }), ); + + // This is meant to handle cases where we are editing an unapprovedTx from the background state + // and its type is a token method. In such a case, the hex data will be the necessary hex data + // for calling the contract transfer method. + // Now that we are updating the transaction to be a send of a native asset type, we should + // set the hex data of the transaction being editing to be empty. + // then the user will not want to send any hex data now that they have change the + if ( + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM || + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER || + unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM + ) { + await dispatch(actions.updateUserInputHexData('')); + } } else { await dispatch(showLoadingIndication()); const details = { @@ -2211,8 +2229,10 @@ export function signTransaction() { draftTransaction.history, ), ); - dispatch(updateEditableParams(draftTransaction.id, editingTx.txParams)); - dispatch( + await dispatch( + updateEditableParams(draftTransaction.id, editingTx.txParams), + ); + await dispatch( updateTransactionGasFees(draftTransaction.id, editingTx.txParams), ); } else { diff --git a/ui/store/actions.js b/ui/store/actions.js index 10893ad33..c177c1e0c 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -745,7 +745,7 @@ export function updateEditableParams(txId, editableParams) { log.error(error.message); throw error; } - + await forceUpdateMetamaskState(dispatch); return updatedTransaction; }; } From 810c00c1088f346d8bd65d91a46047868cbf36e0 Mon Sep 17 00:00:00 2001 From: PeterYinusa <53189696+PeterYinusa@users.noreply.github.com> Date: Wed, 20 Jul 2022 18:39:40 +0100 Subject: [PATCH 11/14] mock contract interaction signature in e2e tests (#15297) --- test/e2e/mock-e2e.js | 22 ++++++++++++++++++++ test/e2e/tests/contract-interactions.spec.js | 2 +- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/test/e2e/mock-e2e.js b/test/e2e/mock-e2e.js index a6d5d6518..99f5c6e57 100644 --- a/test/e2e/mock-e2e.js +++ b/test/e2e/mock-e2e.js @@ -25,6 +25,28 @@ async function setupMocking(server, testSpecificMock) { }; }); + await server + .forGet('https://www.4byte.directory/api/v1/signatures/') + .thenCallback(() => { + return { + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 1, + created_at: null, + text_signature: 'deposit()', + hex_signature: null, + bytes_signature: null, + }, + ], + }, + }; + }); + await server .forGet('https://gas-api.metaswap.codefi.network/networks/1/gasPrices') .thenCallback(() => { diff --git a/test/e2e/tests/contract-interactions.spec.js b/test/e2e/tests/contract-interactions.spec.js index db70f0845..ad0ce7fd6 100644 --- a/test/e2e/tests/contract-interactions.spec.js +++ b/test/e2e/tests/contract-interactions.spec.js @@ -103,7 +103,7 @@ describe('Deploy contract and call contract methods', function () { ); await driver.waitForSelector({ css: '.confirm-page-container-summary__action__name', - text: 'Withdraw', + text: 'Deposit', }); await driver.clickElement({ text: 'Confirm', tag: 'button' }); await driver.waitUntilXWindowHandles(2); From 024a62f4019bb64aac7ffc0979c646fcdbcacc8a Mon Sep 17 00:00:00 2001 From: Brad Decker Date: Mon, 18 Jul 2022 12:01:10 -0500 Subject: [PATCH 12/14] enable direct routing to the send page (#15259) --- ui/ducks/send/send.js | 19 ++++++++-- .../send/send-header/send-header.component.js | 10 ++++-- ui/pages/send/send.js | 29 +++++++++++++-- ui/pages/send/send.test.js | 35 ++++++++++++++++++- 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 725d59bd1..4ca6b6ca4 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -1079,8 +1079,10 @@ const slice = createSlice({ updateGasLimit: (state, action) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; - draftTransaction.gas.gasLimit = addHexPrefix(action.payload); - slice.caseReducers.calculateGasTotal(state); + if (draftTransaction) { + draftTransaction.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); + } }, /** * sets the layer 1 fees total (for a multi-layer fee network) @@ -2346,6 +2348,19 @@ export function getCurrentDraftTransaction(state) { return state[name].draftTransactions[getCurrentTransactionUUID(state)] ?? {}; } +/** + * Selector that returns true if a draft transaction exists. + * + * @type {Selector} + */ +export function getDraftTransactionExists(state) { + const draftTransaction = getCurrentDraftTransaction(state); + if (Object.keys(draftTransaction).length === 0) { + return false; + } + return true; +} + // Gas selectors /** diff --git a/ui/pages/send/send-header/send-header.component.js b/ui/pages/send/send-header/send-header.component.js index f4528a6c4..d71b6ef99 100644 --- a/ui/pages/send/send-header/send-header.component.js +++ b/ui/pages/send/send-header/send-header.component.js @@ -5,6 +5,7 @@ import PageContainerHeader from '../../../components/ui/page-container/page-cont import { getMostRecentOverviewPage } from '../../../ducks/history/history'; import { useI18nContext } from '../../../hooks/useI18nContext'; import { + getDraftTransactionExists, getSendAsset, getSendStage, resetSendState, @@ -19,15 +20,18 @@ export default function SendHeader() { const stage = useSelector(getSendStage); const asset = useSelector(getSendAsset); const t = useI18nContext(); - + const draftTransactionExists = useSelector(getDraftTransactionExists); const onClose = () => { dispatch(resetSendState()); history.push(mostRecentOverviewPage); }; - let title = asset.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); + let title = asset?.type === ASSET_TYPES.NATIVE ? t('send') : t('sendTokens'); - if (stage === SEND_STAGES.ADD_RECIPIENT || stage === SEND_STAGES.INACTIVE) { + if ( + draftTransactionExists === false || + [SEND_STAGES.ADD_RECIPIENT, SEND_STAGES.INACTIVE].includes(stage) + ) { title = t('sendTo'); } else if (stage === SEND_STAGES.EDIT) { title = t('edit'); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js index 060dbfee8..ad10615a7 100644 --- a/ui/pages/send/send.js +++ b/ui/pages/send/send.js @@ -1,8 +1,9 @@ -import React, { useEffect, useCallback, useContext } from 'react'; +import React, { useEffect, useCallback, useContext, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useHistory, useLocation } from 'react-router-dom'; import { addHistoryEntry, + getDraftTransactionExists, getIsUsingMyAccountForRecipientSearch, getRecipient, getRecipientUserInput, @@ -10,6 +11,7 @@ import { resetRecipientInput, resetSendState, SEND_STAGES, + startNewDraftTransaction, updateRecipient, updateRecipientUserInput, } from '../../ducks/send'; @@ -18,6 +20,7 @@ import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask'; import { showQrScanner } from '../../store/actions'; import { MetaMetricsContext } from '../../contexts/metametrics'; import { EVENT } from '../../../shared/constants/metametrics'; +import { ASSET_TYPES } from '../../../shared/constants/transaction'; import SendHeader from './send-header'; import AddRecipient from './send-content/add-recipient'; import SendContent from './send-content'; @@ -29,6 +32,7 @@ const sendSliceIsCustomPriceExcessive = (state) => export default function SendTransactionScreen() { const history = useHistory(); + const startedNewDraftTransaction = useRef(false); const stage = useSelector(getSendStage); const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive); const isUsingMyAccountsForRecipientSearch = useSelector( @@ -37,6 +41,7 @@ export default function SendTransactionScreen() { const recipient = useSelector(getRecipient); const showHexData = useSelector(getSendHexDataFeatureFlagState); const userInput = useSelector(getRecipientUserInput); + const draftTransactionExists = useSelector(getDraftTransactionExists); const location = useLocation(); const trackEvent = useContext(MetaMetricsContext); @@ -46,6 +51,23 @@ export default function SendTransactionScreen() { dispatch(resetSendState()); }, [dispatch]); + /** + * It is possible to route to this page directly, either by typing in the url + * or by clicking the browser back button after progressing to the confirm + * screen. In the case where a draft transaction does not yet exist, this + * hook is responsible for creating it. We will assume that this is a native + * asset send. + */ + useEffect(() => { + if ( + draftTransactionExists === false && + startedNewDraftTransaction.current === false + ) { + startedNewDraftTransaction.current = true; + dispatch(startNewDraftTransaction({ type: ASSET_TYPES.NATIVE })); + } + }, [draftTransactionExists, dispatch]); + useEffect(() => { window.addEventListener('beforeunload', cleanup); }, [cleanup]); @@ -70,7 +92,10 @@ export default function SendTransactionScreen() { let content; - if ([SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage)) { + if ( + draftTransactionExists && + [SEND_STAGES.EDIT, SEND_STAGES.DRAFT].includes(stage) + ) { content = ( <> { + const original = jest.requireActual('../../ducks/send/send'); + return { + ...original, + // We don't really need to start a draft transaction, and the mock store + // does not update as a result of action calls so instead we just ensure + // that the action WOULD be called. + startNewDraftTransaction: jest.fn(() => ({ + type: 'TEST_START_NEW_DRAFT', + payload: null, + })), + }; +}); + jest.mock('react-router-dom', () => { const original = jest.requireActual('react-router-dom'); return { @@ -160,6 +174,25 @@ describe('Send Page', () => { const { queryByText } = renderWithProvider(, store); expect(queryByText('Next')).toBeNull(); }); + + it('should render correctly even when a draftTransaction does not exist', () => { + const modifiedStore = { + ...baseStore, + send: { + ...baseStore.send, + currentTransactionUUID: null, + }, + }; + const store = configureMockStore(middleware)(modifiedStore); + const { getByPlaceholderText } = renderWithProvider(, store); + // Ensure that the send flow renders on the add recipient screen when + // there is no draft transaction. + expect( + getByPlaceholderText('Search, public address (0x), or ENS'), + ).toBeTruthy(); + // Ensure we start a new draft transaction when its missing. + expect(startNewDraftTransaction).toHaveBeenCalledTimes(1); + }); }); describe('Send and Edit Flow (draft)', () => { From f0556cf097164719f2856e14689e32ed2cd9dd87 Mon Sep 17 00:00:00 2001 From: Alex Donesky Date: Thu, 21 Jul 2022 14:39:55 -0500 Subject: [PATCH 13/14] fix blockExplorer link on setApprovalForAll confirmation screen (#15312) --- .../confirm-approve-content.component.js | 7 ++++++- ui/pages/confirm-approve/confirm-approve.js | 1 + 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js index 1e0e1668e..b87c405b9 100644 --- a/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js +++ b/ui/pages/confirm-approve/confirm-approve-content/confirm-approve-content.component.js @@ -73,6 +73,7 @@ export default class ConfirmApproveContent extends Component { assetStandard: PropTypes.string, isSetApproveForAll: PropTypes.bool, setApproveForAllArg: PropTypes.bool, + userAddress: PropTypes.string, }; state = { @@ -454,6 +455,7 @@ export default class ConfirmApproveContent extends Component { assetStandard, tokenSymbol, isSetApproveForAll, + userAddress, } = this.props; const { t } = this.context; let titleTokenDescription = t('token'); @@ -462,6 +464,7 @@ export default class ConfirmApproveContent extends Component { tokenAddress, chainId, null, + userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }, @@ -500,6 +503,7 @@ export default class ConfirmApproveContent extends Component { tokenAddress, chainId, null, + userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }, @@ -581,6 +585,7 @@ export default class ConfirmApproveContent extends Component { rpcPrefs, isContract, assetStandard, + userAddress, } = this.props; const { showFullTxDetails } = this.state; @@ -667,7 +672,7 @@ export default class ConfirmApproveContent extends Component { className="confirm-approve-content__etherscan-link" onClick={() => { const blockExplorerTokenLink = isContract - ? getTokenTrackerLink(toAddress, chainId, null, null, { + ? getTokenTrackerLink(toAddress, chainId, null, userAddress, { blockExplorerUrl: rpcPrefs?.blockExplorerUrl ?? null, }) : getAccountLink( diff --git a/ui/pages/confirm-approve/confirm-approve.js b/ui/pages/confirm-approve/confirm-approve.js index c1b35c24d..072a4d330 100644 --- a/ui/pages/confirm-approve/confirm-approve.js +++ b/ui/pages/confirm-approve/confirm-approve.js @@ -172,6 +172,7 @@ export default function ConfirmApprove({ contentComponent={ Date: Fri, 22 Jul 2022 14:33:16 +0200 Subject: [PATCH 14/14] Added missing PRs on changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e4536d929..06e058fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [10.18.0] ### Added - Add setApprovalForAll confirmation view so granted permissions are displayed in a digested manner, instead of a simple contract interaction([#15010](https://github.com/MetaMask/metamask-extension/pull/15010)) +- Add warning when performing a Send directly to a token contract([#13588](https://github.com/MetaMask/metamask-extension/pull/13588)) ### Changed - Update Optimism ChainID from Kovan to Goerli ([#15119](https://github.com/MetaMask/metamask-extension/pull/15119)) @@ -18,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fix Chinese translation for the message of Importing repeated tokens ([#14994](https://github.com/MetaMask/metamask-extension/pull/14994)) - Fix Japanese translation for the word Sign ([#15078](https://github.com/MetaMask/metamask-extension/pull/15078)) - Fix partially the error "Seedphrase is invalid" by disabling Seedphrase Import button after switching the Seedphrase length ([#15139](https://github.com/MetaMask/metamask-extension/pull/15139)) +- Fix Edit Transaction flow by ensuring that changing a tx from a Transfer to a Send resets data and updates tx type ([#15248](https://github.com/MetaMask/metamask-extension/pull/15248)) +- Fix UI on Import Seedphrase page by disabling Import button, if any of the characters of the Seedphrase is in uppercase ([#15186](https://github.com/MetaMask/metamask-extension/pull/15186)) ## [10.17.0] ### Added