diff --git a/shared/constants/tokens.js b/shared/constants/tokens.js index c4e52317a..2a878f3df 100644 --- a/shared/constants/tokens.js +++ b/shared/constants/tokens.js @@ -8,3 +8,16 @@ import contractMap from '@metamask/contract-metadata'; export const LISTED_CONTRACT_ADDRESSES = Object.keys( contractMap, ).map((address) => address.toLowerCase()); + +/** + * @typedef {Object} TokenDetails + * @property {string} address - The address of the selected 'TOKEN' or + * 'COLLECTIBLE' contract. + * @property {string} [symbol] - The symbol of the token. + * @property {number} [decimals] - The number of decimals of the selected + * 'ERC20' asset. + * @property {number} [tokenId] - The id of the selected 'COLLECTIBLE' asset. + * @property {TokenStandardStrings} [standard] - The standard of the selected + * asset. + * @property {boolean} [isERC721] - True when the asset is a ERC721 token. + */ diff --git a/test/jest/mocks.js b/test/jest/mocks.js index 6e257adaa..47c8a1f99 100644 --- a/test/jest/mocks.js +++ b/test/jest/mocks.js @@ -1,3 +1,8 @@ +import { + draftTransactionInitialState, + initialState, +} from '../../ui/ducks/send'; + export const TOP_ASSETS_GET_RESPONSE = [ { symbol: 'LINK', @@ -103,3 +108,42 @@ export const createGasFeeEstimatesForFeeMarket = () => { estimatedBaseFee: '50', }; }; + +export const INITIAL_SEND_STATE_FOR_EXISTING_DRAFT = { + ...initialState, + currentTransactionUUID: 'test-uuid', + draftTransactions: { + 'test-uuid': { + ...draftTransactionInitialState, + }, + }, +}; + +export const getInitialSendStateWithExistingTxState = (draftTxState) => ({ + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + draftTransactions: { + 'test-uuid': { + ...draftTransactionInitialState, + ...draftTxState, + amount: { + ...draftTransactionInitialState.amount, + ...draftTxState.amount, + }, + asset: { + ...draftTransactionInitialState.asset, + ...draftTxState.asset, + }, + gas: { + ...draftTransactionInitialState.gas, + ...draftTxState.gas, + }, + recipient: { + ...draftTransactionInitialState.recipient, + ...draftTxState.recipient, + }, + history: draftTxState.history ?? [], + // Use this key if you want to console.log inside the send.js file. + test: draftTxState.test ?? 'yo', + }, + }, +}); diff --git a/ui/components/app/asset-list-item/asset-list-item.js b/ui/components/app/asset-list-item/asset-list-item.js index 77ab7f53d..bc19aff87 100644 --- a/ui/components/app/asset-list-item/asset-list-item.js +++ b/ui/components/app/asset-list-item/asset-list-item.js @@ -9,7 +9,7 @@ import Tooltip from '../../ui/tooltip'; import InfoIcon from '../../ui/icon/info-icon.component'; import Button from '../../ui/button'; import { useI18nContext } from '../../../hooks/useI18nContext'; -import { updateSendAsset } from '../../../ducks/send'; +import { startNewDraftTransaction } from '../../../ducks/send'; import { SEND_ROUTE } from '../../../helpers/constants/routes'; import { SEVERITIES } from '../../../helpers/constants/design-system'; import { INVALID_ASSET_TYPE } from '../../../helpers/constants/error-keys'; @@ -74,7 +74,7 @@ const AssetListItem = ({ }); try { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.TOKEN, details: { address: tokenAddress, diff --git a/ui/components/app/collectible-details/collectible-details.js b/ui/components/app/collectible-details/collectible-details.js index 53078c457..df991634e 100644 --- a/ui/components/app/collectible-details/collectible-details.js +++ b/ui/components/app/collectible-details/collectible-details.js @@ -45,7 +45,7 @@ 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 { updateSendAsset } from '../../../ducks/send'; +import { startNewDraftTransaction } from '../../../ducks/send'; import InfoTooltip from '../../ui/info-tooltip'; import { ERC721 } from '../../../helpers/constants/common'; import { usePrevious } from '../../../hooks/usePrevious'; @@ -120,7 +120,7 @@ export default function CollectibleDetails({ collectible }) { const onSend = async () => { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.COLLECTIBLE, details: collectible, }), diff --git a/ui/components/app/wallet-overview/eth-overview.js b/ui/components/app/wallet-overview/eth-overview.js index 4b9e22a41..26b0019b1 100644 --- a/ui/components/app/wallet-overview/eth-overview.js +++ b/ui/components/app/wallet-overview/eth-overview.js @@ -33,6 +33,8 @@ import { isHardwareKeyring } from '../../../helpers/utils/hardware'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { EVENT } from '../../../../shared/constants/metametrics'; import Spinner from '../../ui/spinner'; +import { startNewDraftTransaction } from '../../../ducks/send'; +import { ASSET_TYPES } from '../../../../shared/constants/transaction'; import WalletOverview from './wallet-overview'; const EthOverview = ({ className }) => { @@ -131,7 +133,11 @@ const EthOverview = ({ className }) => { legacy_event: true, }, }); - history.push(SEND_ROUTE); + dispatch( + startNewDraftTransaction({ type: ASSET_TYPES.NATIVE }), + ).then(() => { + history.push(SEND_ROUTE); + }); }} /> { }); try { await dispatch( - updateSendAsset({ + startNewDraftTransaction({ type: ASSET_TYPES.TOKEN, details: token, }), diff --git a/ui/components/ui/token-input/token-input.component.js b/ui/components/ui/token-input/token-input.component.js index 6de1191dc..5f2cab056 100644 --- a/ui/components/ui/token-input/token-input.component.js +++ b/ui/components/ui/token-input/token-input.component.js @@ -118,7 +118,7 @@ export default class TokenInput extends PureComponent { isEqualCaseInsensitive(address, token.address), ); - const tokenExchangeRate = tokenExchangeRates?.[existingToken.address] || 0; + const tokenExchangeRate = tokenExchangeRates?.[existingToken?.address] ?? 0; let currency, numberOfDecimals; if (hideConversion) { diff --git a/ui/ducks/send/helpers.js b/ui/ducks/send/helpers.js new file mode 100644 index 000000000..f1233a9c1 --- /dev/null +++ b/ui/ducks/send/helpers.js @@ -0,0 +1,295 @@ +import { addHexPrefix } from 'ethereumjs-util'; +import abi from 'human-standard-token-abi'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; +import { + ASSET_TYPES, + TRANSACTION_ENVELOPE_TYPES, +} from '../../../shared/constants/transaction'; +import { readAddressAsContract } from '../../../shared/modules/contract-utils'; +import { + conversionUtil, + multiplyCurrencies, +} from '../../../shared/modules/conversion.utils'; +import { ETH, GWEI } from '../../helpers/constants/common'; +import { calcTokenAmount } from '../../helpers/utils/token-util'; +import { MIN_GAS_LIMIT_HEX } from '../../pages/send/send.constants'; +import { + addGasBuffer, + generateERC20TransferData, + generateERC721TransferData, + getAssetTransferData, +} from '../../pages/send/send.utils'; +import { getGasPriceInHexWei } from '../../selectors'; +import { estimateGas } from '../../store/actions'; + +export async function estimateGasLimitForSend({ + selectedAddress, + value, + gasPrice, + sendToken, + to, + data, + isNonStandardEthChain, + chainId, + gasLimit, + ...options +}) { + let isSimpleSendOnNonStandardNetwork = false; + + // blockGasLimit may be a falsy, but defined, value when we receive it from + // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. Some + // network implementations check the gas parameter supplied to + // eth_estimateGas for validity. For this reason, we set token sends + // blockGasLimit default to a higher number. Note that the current gasLimit + // on a BLOCK is 15,000,000 and will be 30,000,000 on mainnet after London. + // Meanwhile, MIN_GAS_LIMIT_HEX is 0x5208. + let blockGasLimit = MIN_GAS_LIMIT_HEX; + if (options.blockGasLimit) { + blockGasLimit = options.blockGasLimit; + } else if (sendToken) { + blockGasLimit = GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + + // The parameters below will be sent to our background process to estimate + // how much gas will be used for a transaction. That background process is + // located in tx-gas-utils.js in the transaction controller folder. + const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; + + if (sendToken) { + if (!to) { + // If no to address is provided, we cannot generate the token transfer + // hexData. hexData in a transaction largely dictates how much gas will + // be consumed by a transaction. We must use our best guess, which is + // represented in the gas shared constants. + return GAS_LIMITS.BASE_TOKEN_ESTIMATE; + } + paramsForGasEstimate.value = '0x0'; + + // We have to generate the erc20/erc721 contract call to transfer tokens in + // order to get a proper estimate for gasLimit. + paramsForGasEstimate.data = getAssetTransferData({ + sendToken, + fromAddress: selectedAddress, + toAddress: to, + amount: value, + }); + + paramsForGasEstimate.to = sendToken.address; + } else { + if (!data) { + // eth.getCode will return the compiled smart contract code at the + // address. If this returns 0x, 0x0 or a nullish value then the address + // is an externally owned account (NOT a contract account). For these + // types of transactions the gasLimit will always be 21,000 or 0x5208 + const { isContractAddress } = to + ? await readAddressAsContract(global.eth, to) + : {}; + if (!isContractAddress && !isNonStandardEthChain) { + return GAS_LIMITS.SIMPLE; + } else if (!isContractAddress && isNonStandardEthChain) { + isSimpleSendOnNonStandardNetwork = true; + } + } + + paramsForGasEstimate.data = data; + + if (to) { + paramsForGasEstimate.to = to; + } + + if (!value || value === '0') { + // TODO: Figure out what's going on here. According to eth_estimateGas + // docs this value can be zero, or undefined, yet we are setting it to a + // value here when the value is undefined or zero. For more context: + // https://github.com/MetaMask/metamask-extension/pull/6195 + paramsForGasEstimate.value = '0xff'; + } + } + + if (!isSimpleSendOnNonStandardNetwork) { + // If we do not yet have a gasLimit, we must call into our background + // process to get an estimate for gasLimit based on known parameters. + + paramsForGasEstimate.gas = addHexPrefix( + multiplyCurrencies(blockGasLimit, 0.95, { + multiplicandBase: 16, + multiplierBase: 10, + roundDown: '0', + toNumericBase: 'hex', + }), + ); + } + + // The buffer multipler reduces transaction failures by ensuring that the + // estimated gas is always sufficient. Without the multiplier, estimates + // for contract interactions can become inaccurate over time. This is because + // gas estimation is non-deterministic. The gas required for the exact same + // transaction call can change based on state of a contract or changes in the + // contracts environment (blockchain data or contracts it interacts with). + // Applying the 1.5 buffer has proven to be a useful guard against this non- + // deterministic behaviour. + // + // Gas estimation of simple sends should, however, be deterministic. As such + // no buffer is needed in those cases. + let bufferMultiplier = 1.5; + if (isSimpleSendOnNonStandardNetwork) { + bufferMultiplier = 1; + } else if (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]) { + bufferMultiplier = CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]; + } + + try { + // Call into the background process that will simulate transaction + // execution on the node and return an estimate of gasLimit + const estimatedGasLimit = await estimateGas(paramsForGasEstimate); + const estimateWithBuffer = addGasBuffer( + estimatedGasLimit, + blockGasLimit, + bufferMultiplier, + ); + return addHexPrefix(estimateWithBuffer); + } catch (error) { + const simulationFailed = + error.message.includes('Transaction execution error.') || + error.message.includes( + 'gas required exceeds allowance or always failing transaction', + ) || + (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId] && + error.message.includes('gas required exceeds allowance')); + if (simulationFailed) { + const estimateWithBuffer = addGasBuffer( + paramsForGasEstimate?.gas ?? gasLimit, + blockGasLimit, + bufferMultiplier, + ); + return addHexPrefix(estimateWithBuffer); + } + throw error; + } +} + +/** + * Generates a txParams from the send slice. + * + * @param {import('.').SendState} sendState - the state of the send slice + * @returns {import( + * '../../../shared/constants/transaction' + * ).TxParams} A txParams object that can be used to create a transaction or + * update an existing transaction. + */ +export function generateTransactionParams(sendState) { + const draftTransaction = + sendState.draftTransactions[sendState.currentTransactionUUID]; + const txParams = { + // If the fromAccount has been specified we use that, if not we use the + // selected account. + from: + draftTransaction.fromAccount?.address || + sendState.selectedAccount.address, + // gasLimit always needs to be set regardless of the asset being sent + // or the type of transaction. + gas: draftTransaction.gas.gasLimit, + }; + switch (draftTransaction.asset.type) { + case ASSET_TYPES.TOKEN: + // 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. + txParams.to = draftTransaction.asset.details.address; + txParams.value = '0x0'; + txParams.data = generateERC20TransferData({ + toAddress: draftTransaction.recipient.address, + amount: draftTransaction.amount.value, + sendToken: draftTransaction.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. + txParams.to = draftTransaction.asset.details.address; + txParams.value = '0x0'; + txParams.data = generateERC721TransferData({ + toAddress: draftTransaction.recipient.address, + fromAddress: + draftTransaction.fromAccount?.address ?? + sendState.selectedAccount.address, + tokenId: draftTransaction.asset.details.tokenId, + }); + break; + case ASSET_TYPES.NATIVE: + default: + // When sending native currency the to and value fields use the + // recipient and amount values and the data key is either null or + // populated with the user input provided in hex field. + txParams.to = draftTransaction.recipient.address; + txParams.value = draftTransaction.amount.value; + txParams.data = draftTransaction.userInputHexData ?? undefined; + } + + // We need to make sure that we only include the right gas fee fields + // based on the type of transaction the network supports. We will also set + // the type param here. + if (sendState.eip1559support) { + txParams.type = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + + txParams.maxFeePerGas = draftTransaction.gas.maxFeePerGas; + txParams.maxPriorityFeePerGas = draftTransaction.gas.maxPriorityFeePerGas; + + if (!txParams.maxFeePerGas || txParams.maxFeePerGas === '0x0') { + txParams.maxFeePerGas = draftTransaction.gas.gasPrice; + } + + if ( + !txParams.maxPriorityFeePerGas || + txParams.maxPriorityFeePerGas === '0x0' + ) { + txParams.maxPriorityFeePerGas = txParams.maxFeePerGas; + } + } else { + txParams.gasPrice = draftTransaction.gas.gasPrice; + txParams.type = TRANSACTION_ENVELOPE_TYPES.LEGACY; + } + + return txParams; +} + +/** + * This method is used to keep the original logic from the gas.duck.js file + * after receiving a gasPrice from eth_gasPrice. First, the returned gasPrice + * was converted to GWEI, then it was converted to a Number, then in the send + * duck (here) we would use getGasPriceInHexWei to get back to hexWei. Now that + * we receive a GWEI estimate from the controller, we still need to do this + * weird conversion to get the proper rounding. + * + * @param {string} gasPriceEstimate + * @returns {string} + */ +export function getRoundedGasPrice(gasPriceEstimate) { + const gasPriceInDecGwei = conversionUtil(gasPriceEstimate, { + numberOfDecimals: 9, + toDenomination: GWEI, + fromNumericBase: 'dec', + toNumericBase: 'dec', + fromCurrency: ETH, + fromDenomination: GWEI, + }); + const gasPriceAsNumber = Number(gasPriceInDecGwei); + return getGasPriceInHexWei(gasPriceAsNumber); +} + +export async function getERC20Balance(token, accountAddress) { + const contract = global.eth.contract(abi).at(token.address); + const usersToken = (await contract.balanceOf(accountAddress)) ?? null; + if (!usersToken) { + return '0x0'; + } + const amount = calcTokenAmount( + usersToken.balance.toString(), + token.decimals, + ).toString(16); + return addHexPrefix(amount); +} diff --git a/ui/ducks/send/helpers.test.js b/ui/ducks/send/helpers.test.js new file mode 100644 index 000000000..a8ec656a9 --- /dev/null +++ b/ui/ducks/send/helpers.test.js @@ -0,0 +1,163 @@ +import { ethers } from 'ethers'; +import { GAS_LIMITS } from '../../../shared/constants/gas'; +import { + ASSET_TYPES, + TRANSACTION_ENVELOPE_TYPES, +} from '../../../shared/constants/transaction'; +import { BURN_ADDRESS } from '../../../shared/modules/hexstring-utils'; +import { getInitialSendStateWithExistingTxState } from '../../../test/jest/mocks'; +import { TOKEN_STANDARDS } from '../../helpers/constants/common'; +import { + generateERC20TransferData, + generateERC721TransferData, +} from '../../pages/send/send.utils'; +import { generateTransactionParams } from './helpers'; + +describe('Send Slice Helpers', () => { + describe('generateTransactionParams', () => { + it('should generate a txParams for a token transfer', () => { + const tokenDetails = { + address: '0xToken', + symbol: 'SYMB', + decimals: 18, + }; + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.TOKEN, + balance: '0xaf', + details: tokenDetails, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: generateERC20TransferData({ + toAddress: BURN_ADDRESS, + amount: '0x1', + sendToken: tokenDetails, + }), + to: '0xToken', + type: '0x0', + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a collectible transfer', () => { + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.COLLECTIBLE, + balance: '0xaf', + details: { + address: '0xToken', + standard: TOKEN_STANDARDS.ERC721, + tokenId: ethers.BigNumber.from(15000).toString(), + }, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: generateERC721TransferData({ + toAddress: BURN_ADDRESS, + fromAddress: '0x00', + tokenId: ethers.BigNumber.from(15000).toString(), + }), + to: '0xToken', + type: '0x0', + value: '0x0', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a native legacy transaction', () => { + const txParams = generateTransactionParams( + getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0xaf', + details: null, + }, + recipient: { + address: BURN_ADDRESS, + }, + }), + ); + expect(txParams).toStrictEqual({ + from: '0x00', + data: undefined, + to: BURN_ADDRESS, + type: '0x0', + value: '0x1', + gas: '0x0', + gasPrice: '0x0', + }); + }); + + it('should generate a txParams for a native fee market transaction', () => { + const txParams = generateTransactionParams({ + ...getInitialSendStateWithExistingTxState({ + fromAccount: { + address: '0x00', + }, + amount: { + value: '0x1', + }, + asset: { + type: ASSET_TYPES.NATIVE, + balance: '0xaf', + details: null, + }, + recipient: { + address: BURN_ADDRESS, + }, + gas: { + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + gasLimit: GAS_LIMITS.SIMPLE, + }, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + }), + eip1559support: true, + }); + expect(txParams).toStrictEqual({ + from: '0x00', + data: undefined, + to: BURN_ADDRESS, + type: '0x2', + value: '0x1', + gas: GAS_LIMITS.SIMPLE, + maxFeePerGas: '0x2', + maxPriorityFeePerGas: '0x1', + }); + }); + }); +}); diff --git a/ui/ducks/send/send.js b/ui/ducks/send/send.js index 2b3d59f12..f27a9d6a5 100644 --- a/ui/ducks/send/send.js +++ b/ui/ducks/send/send.js @@ -1,8 +1,8 @@ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import abi from 'human-standard-token-abi'; import BigNumber from 'bignumber.js'; import { addHexPrefix } from 'ethereumjs-util'; import { debounce } from 'lodash'; +import { v4 as uuidv4 } from 'uuid'; import { conversionGreaterThan, conversionUtil, @@ -17,26 +17,19 @@ import { INVALID_RECIPIENT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, KNOWN_RECIPIENT_ADDRESS_WARNING, - MIN_GAS_LIMIT_HEX, NEGATIVE_ETH_ERROR, } from '../../pages/send/send.constants'; import { - addGasBuffer, calcGasTotal, - generateERC20TransferData, - generateERC721TransferData, - getAssetTransferData, isBalanceSufficient, isTokenBalanceSufficient, } from '../../pages/send/send.utils'; import { - getAddressBookEntry, getAdvancedInlineGasShown, getCurrentChainId, getGasPriceInHexWei, getIsMainnet, - getSelectedAddress, getTargetAccount, getIsNonStandardEthChain, checkNetworkAndAccountSupports1559, @@ -45,11 +38,12 @@ import { getAddressBookEntryOrAccountName, getIsMultiLayerFeeNetwork, getEnsResolutionByAddress, + getSelectedAccount, + getSelectedAddress, } from '../../selectors'; import { disconnectGasFeeEstimatePoller, displayWarning, - estimateGas, getGasFeeEstimatesAndStartPolling, hideLoadingIndication, showLoadingIndication, @@ -97,17 +91,21 @@ import { import { sumHexes } from '../../helpers/utils/transactions.util'; import fetchEstimatedL1Fee from '../../helpers/utils/optimism/fetchEstimatedL1Fee'; -import { CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP } from '../../../shared/constants/network'; -import { TOKEN_STANDARDS, ETH, GWEI } from '../../helpers/constants/common'; +import { TOKEN_STANDARDS, ETH } from '../../helpers/constants/common'; import { ASSET_TYPES, TRANSACTION_ENVELOPE_TYPES, TRANSACTION_TYPES, } from '../../../shared/constants/transaction'; -import { readAddressAsContract } from '../../../shared/modules/contract-utils'; import { INVALID_ASSET_TYPE } from '../../helpers/constants/error-keys'; import { isEqualCaseInsensitive } from '../../../shared/modules/string-utils'; import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; +import { parseStandardTokenTransactionData } from '../../../shared/modules/transaction.utils'; +import { + estimateGasLimitForSend, + generateTransactionParams, + getRoundedGasPrice, +} from './helpers'; // typedef import statements /** * @typedef {( @@ -120,6 +118,9 @@ import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; * import( '../../helpers/constants/common').TokenStandardStrings * )} TokenStandardStrings * @typedef {( + * import( '../../../shared/constants/tokens').TokenDetails + * )} TokenDetails + * @typedef {( * import('../../../shared/constants/transaction').TransactionTypeString * )} TransactionTypeString * @typedef {( @@ -134,14 +135,28 @@ import { getValueFromWeiHex } from '../../helpers/utils/confirm-tx.util'; * @typedef {( * import('@metamask/controllers').GasEstimateType * )} GasEstimateType + * @typedef {( + * import('redux').AnyAction + * )} AnyAction */ -const name = 'send'; +/** + * @template R - Return type of the async function + * @typedef {( + * import('redux-thunk').ThunkAction + * )} ThunkAction + */ + +/** + * This type will take a typical constant string mapped object and turn it into + * a union type of the values. + * + * @template O - The object to make strings out of + * @typedef {O[keyof O]} MapValuesToUnion + */ /** * @typedef {Object} SendStateStages - * @property {'INACTIVE'} INACTIVE - The send state is idle, and hasn't yet - * fetched required data for gasPrice and gasLimit estimations, etc. * @property {'ADD_RECIPIENT'} ADD_RECIPIENT - The user is selecting which * address to send an asset to. * @property {'DRAFT'} DRAFT - The send form is shown for a transaction yet to @@ -150,13 +165,8 @@ const name = 'send'; * submitted to the Transaction Controller but not yet confirmed. This happens * when a confirmation is shown for a transaction and the 'edit' button in the * header is clicked. - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above Stages - * - * @typedef {SendStateStages[keyof SendStateStages]} SendStateStagesStrings + * @property {'INACTIVE'} INACTIVE - The send state is idle, and hasn't yet + * fetched required data for gasPrice and gasLimit estimations, etc. */ /** @@ -165,15 +175,14 @@ const name = 'send'; * @type {SendStateStages} */ export const SEND_STAGES = { - INACTIVE: 'INACTIVE', ADD_RECIPIENT: 'ADD_RECIPIENT', DRAFT: 'DRAFT', EDIT: 'EDIT', + INACTIVE: 'INACTIVE', }; /** - * @typedef {Object} SendStateStatuses - * @property {'VALID'} VALID - The transaction is valid and can be submitted. + * @typedef {Object} DraftTxStatus * @property {'INVALID'} INVALID - The transaction is invalid and cannot be * submitted. There are a number of cases that would result in an invalid * send state: @@ -184,41 +193,28 @@ export const SEND_STAGES = { * 4. The amount of sent asset is greater than the user's *asset* balance * 5. Gas price estimates failed to load entirely * 6. The gasLimit is less than 21000 (0x5208) - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above statuses - * - * @typedef {SendStateStatuses[keyof SendStateStatuses]} SendStateStatusStrings + * @property {'VALID'} VALID - The transaction is valid and can be submitted. */ /** * The status of the send slice * - * @type {SendStateStatuses} + * @type {DraftTxStatus} */ export const SEND_STATUSES = { - VALID: 'VALID', INVALID: 'INVALID', + VALID: 'VALID', }; /** * @typedef {Object} SendStateGasModes * @property {'BASIC'} BASIC - Shows the basic estimate slow/avg/fast buttons * when on mainnet and the metaswaps API request is successful. - * @property {'INLINE'} INLINE - Shows inline gasLimit/gasPrice fields when on - * any other network or metaswaps API fails and we use eth_gasPrice. * @property {'CUSTOM'} CUSTOM - Shows GasFeeDisplay component that is a read * only display of the values the user has set in the advanced gas modal * (stored in the gas duck under the customData key). - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above gas modes - * - * @typedef {SendStateGasModes[keyof SendStateGasModes]} SendStateGasModeStrings + * @property {'INLINE'} INLINE - Shows inline gasLimit/gasPrice fields when on + * any other network or metaswaps API fails and we use eth_gasPrice. */ /** @@ -228,8 +224,8 @@ export const SEND_STATUSES = { */ export const GAS_INPUT_MODES = { BASIC: 'BASIC', - INLINE: 'INLINE', CUSTOM: 'CUSTOM', + INLINE: 'INLINE', }; /** @@ -240,13 +236,6 @@ export const GAS_INPUT_MODES = { * calculated based on balance - (amount + gasTotal). */ -/** - * This type will work anywhere you expect a string that can be one of the - * above gas modes - * - * @typedef {SendStateAmountModes[keyof SendStateAmountModes]} SendStateAmountModeStrings - */ - /** * The modes that the amount field can be set by * @@ -259,17 +248,10 @@ export const AMOUNT_MODES = { /** * @typedef {Object} SendStateRecipientModes - * @property {'MY_ACCOUNTS'} MY_ACCOUNTS - the user is displayed a list of - * their own accounts to send to. * @property {'CONTACT_LIST'} CONTACT_LIST - The user is displayed a list of * their contacts and addresses they have recently send to. - */ - -/** - * This type will work anywhere you expect a string that can be one of the - * above recipient modes - * - * @typedef {SendStateRecipientModes[keyof SendStateRecipientModes]} SendStateRecipientModeStrings + * @property {'MY_ACCOUNTS'} MY_ACCOUNTS - the user is displayed a list of + * their own accounts to send to. */ /** @@ -278,168 +260,216 @@ export const AMOUNT_MODES = { * @type {SendStateRecipientModes} */ export const RECIPIENT_SEARCH_MODES = { - MY_ACCOUNTS: 'MY_ACCOUNTS', CONTACT_LIST: 'CONTACT_LIST', + MY_ACCOUNTS: 'MY_ACCOUNTS', }; -async function estimateGasLimitForSend({ - selectedAddress, - value, - gasPrice, - sendToken, - to, - data, - isNonStandardEthChain, - chainId, - gasLimit, - ...options -}) { - let isSimpleSendOnNonStandardNetwork = false; +/** + * @typedef {Object} Account + * @property {string} address - The hex address of the account. + * @property {string} balance - Hex string representing the native asset + * balance of the account the transaction will be sent from. + */ - // blockGasLimit may be a falsy, but defined, value when we receive it from - // state, so we use logical or to fall back to MIN_GAS_LIMIT_HEX. Some - // network implementations check the gas parameter supplied to - // eth_estimateGas for validity. For this reason, we set token sends - // blockGasLimit default to a higher number. Note that the current gasLimit - // on a BLOCK is 15,000,000 and will be 30,000,000 on mainnet after London. - // Meanwhile, MIN_GAS_LIMIT_HEX is 0x5208. - let blockGasLimit = MIN_GAS_LIMIT_HEX; - if (options.blockGasLimit) { - blockGasLimit = options.blockGasLimit; - } else if (sendToken) { - blockGasLimit = GAS_LIMITS.BASE_TOKEN_ESTIMATE; - } +/** + * @typedef {Object} Amount + * @property {string} [error] - Error to display for the amount field. + * @property {string} value - A hex string representing the amount of the + * selected currency to send. + */ - // The parameters below will be sent to our background process to estimate - // how much gas will be used for a transaction. That background process is - // located in tx-gas-utils.js in the transaction controller folder. - const paramsForGasEstimate = { from: selectedAddress, value, gasPrice }; +/** + * @typedef {Object} Asset + * @property {string} balance - A hex string representing the balance + * that the user holds of the asset that they are attempting to send. + * @property {TokenDetails} [details] - An object that describes the + * selected asset in the case that the user is sending a token or collectibe. + * Will be null when asset.type is 'NATIVE'. + * @property {string} [error] - Error to display when there is an issue + * with the asset. + * @property {AssetTypesString} type - The type of asset that the user + * is attempting to send. Defaults to 'NATIVE' which represents the native + * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. + */ - if (sendToken) { - if (!to) { - // if no to address is provided, we cannot generate the token transfer - // hexData. hexData in a transaction largely dictates how much gas will - // be consumed by a transaction. We must use our best guess, which is - // represented in the gas shared constants. - return GAS_LIMITS.BASE_TOKEN_ESTIMATE; - } - paramsForGasEstimate.value = '0x0'; +/** + * @typedef {Object} GasFees + * @property {string} [error] - error to display for gas fields. + * @property {string} gasLimit - maximum gas needed for tx. + * @property {string} gasPrice - price in wei to pay per gas. + * @property {string} gasTotal - maximum total price in wei to pay. + * @property {string} maxFeePerGas - Maximum price in wei to pay per gas. + * @property {string} maxPriorityFeePerGas - Maximum priority fee in wei to pay + * per gas. + */ - // We have to generate the erc20/erc721 contract call to transfer tokens in - // order to get a proper estimate for gasLimit. - paramsForGasEstimate.data = getAssetTransferData({ - sendToken, - fromAddress: selectedAddress, - toAddress: to, - amount: value, - }); +/** + * An object that describes the intended recipient of a transaction. + * + * @typedef {Object} Recipient + * @property {string} address - The fully qualified address of the recipient. + * This is set after the recipient.userInput is validated, the userInput field + * is quickly updated to avoid delay between keystrokes and seeing the input + * field updated. After a debounce the address typed is validated and then the + * address field is updated. The address field is also set when the user + * selects a contact or account from the list, or an ENS resolution when + * typing ENS names. + * @property {string} [error] - Error to display on the address field. + * @property {string} nickname - The nickname that the user has added to their + * address book for the recipient.address. + * @property {string} [warning] - Warning to display on the address field. + */ - paramsForGasEstimate.to = sendToken.address; - } else { - if (!data) { - // eth.getCode will return the compiled smart contract code at the - // address. If this returns 0x, 0x0 or a nullish value then the address - // is an externally owned account (NOT a contract account). For these - // types of transactions the gasLimit will always be 21,000 or 0x5208 - const { isContractAddress } = to - ? await readAddressAsContract(global.eth, to) - : {}; - if (!isContractAddress && !isNonStandardEthChain) { - return GAS_LIMITS.SIMPLE; - } else if (!isContractAddress && isNonStandardEthChain) { - isSimpleSendOnNonStandardNetwork = true; - } - } +/** + * @typedef {Object} DraftTransaction + * @property {Amount} amount - An object containing information about the + * amount of currency to send. + * @property {Asset} asset - An object that describes the asset that the user + * has selected to send. + * @property {Account} [fromAccount] - The send flow is usually only relative to + * the currently selected account. When editing a transaction, however, the + * account may differ. In that case, the details of that account will be + * stored in this object within the draftTransaction. + * @property {GasFees} gas - Details about the current gas settings + * @property {Array<{event: string, timestamp: number}>} history - An array of + * entries that describe the user's journey through the send flow. This is + * sent to the controller for attaching to state logs for troubleshooting and + * support. + * @property {string} [id] - If the transaction has already been added to the + * TransactionController this field will be populated with its id from the + * TransactionController state. This is required to be able to update the + * transaction in the controller. + * @property {Recipient} recipient - An object that describes the intended + * recipient of the transaction. + * @property {MapValuesToUnion} status - Describes the + * validity of the draft transaction, which will be either 'VALID' or + * 'INVALID', depending on our ability to generate a valid txParams object for + * submission. + * @property {string} transactionType - Determines type of transaction being + * sent, defaulted to 0x0 (legacy). + * @property {string} [userInputHexData] - When a user has enabled custom hex + * data field in advanced options, they can supply data to the field which is + * stored under this key. + */ - paramsForGasEstimate.data = data; +/** + * @type {DraftTransaction} + */ +export const draftTransactionInitialState = { + amount: { + error: null, + value: '0x0', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: null, + gas: { + error: null, + gasLimit: '0x0', + gasPrice: '0x0', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: [], + id: null, + recipient: { + address: '', + error: null, + nickname: '', + warning: null, + }, + status: SEND_STATUSES.VALID, + transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, + userInputHexData: null, +}; - if (to) { - paramsForGasEstimate.to = to; - } +/** + * Describes the state tree of the send slice + * + * @typedef {Object} SendState + * @property {MapValuesToUnion} amountMode - Describe + * whether the user has manually input an amount or if they have selected max + * to send the maximum amount of the selected currency. + * @property {string} currentTransactionUUID - The UUID of the transaction + * currently being modified by the send flow. This UUID is generated upon + * initialization of the send flow, any previous UUIDs are discarded at + * clean up AND during initialization. When a transaction is edited a new UUID + * is generated for it and the state of that transaction is copied into a new + * entry in the draftTransactions object. + * @property {Object.} draftTransactions - An object keyed + * by UUID with draftTransactions as the values. + * @property {boolean} eip1559support - tracks whether the current network + * supports EIP 1559 transactions. + * @property {boolean} gasEstimateIsLoading - Indicates whether the gas + * estimate is loading. + * @property {string} [gasEstimatePollToken] - String token identifying a + * listener for polling on the gasFeeController + * @property {boolean} gasIsSetInModal - true if the user set custom gas in the + * custom gas modal + * @property {string} gasLimitMinimum - minimum supported gasLimit. + * @property {string} gasPriceEstimate - Expected price in wei necessary to + * pay per gas used for a transaction to be included in a reasonable timeframe. + * Comes from the GasFeeController. + * @property {string} gasTotalForLayer1 - Layer 1 gas fee total on multi-layer + * fee networks + * @property {string} recipientInput - The user input of the recipient + * which is updated quickly to avoid delays in the UI reflecting manual entry + * of addresses. + * @property {MapValuesToUnion} recipientMode - + * Describes which list of recipients the user is shown on the add recipient + * screen. When this key is set to 'MY_ACCOUNTS' the user is shown the list of + * accounts they own. When it is 'CONTACT_LIST' the user is shown the list of + * contacts they have saved in MetaMask and any addresses they have recently + * sent to. + * @property {Account} selectedAccount - The currently selected account in + * MetaMask. Native balance and address will be pulled from this account if a + * fromAccount is not specified in the draftTransaction object. During an edit + * the fromAccount is specified. + * @property {MapValuesToUnion} stage - The stage of the + * send flow that the user has progressed to. Defaults to 'INACTIVE' which + * results in the send screen not being shown. + */ - if (!value || value === '0') { - // TODO: Figure out what's going on here. According to eth_estimateGas - // docs this value can be zero, or undefined, yet we are setting it to a - // value here when the value is undefined or zero. For more context: - // https://github.com/MetaMask/metamask-extension/pull/6195 - paramsForGasEstimate.value = '0xff'; - } - } +/** + * @type {SendState} + */ +export const initialState = { + amountMode: AMOUNT_MODES.INPUT, + currentTransactionUUID: null, + draftTransactions: {}, + eip1559support: false, + gasEstimateIsLoading: true, + gasEstimatePollToken: null, + gasIsSetInModal: false, + gasPriceEstimate: '0x0', + gasLimitMinimum: GAS_LIMITS.SIMPLE, + gasTotalForLayer1: '0x0', + recipientMode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, + recipientInput: '', + selectedAccount: { + address: null, + balance: '0x0', + }, + stage: SEND_STAGES.INACTIVE, +}; - if (!isSimpleSendOnNonStandardNetwork) { - // If we do not yet have a gasLimit, we must call into our background - // process to get an estimate for gasLimit based on known parameters. +/** + * TODO: We really need to start creating the metamask state type, and the + * entire state tree of redux. Would be *extremely* valuable in future + * typescript conversions. The metamask key is typed as an object on purpose + * here because I cannot go so far in this work as to type that entire object. + * + * @typedef {Object} MetaMaskState + * @property {SendState} send - The state of the send flow. + * @property {Object} metamask - The state of the metamask store. + */ - paramsForGasEstimate.gas = addHexPrefix( - multiplyCurrencies(blockGasLimit, 0.95, { - multiplicandBase: 16, - multiplierBase: 10, - roundDown: '0', - toNumericBase: 'hex', - }), - ); - } - - // The buffer multipler reduces transaction failures by ensuring that the - // estimated gas is always sufficient. Without the multiplier, estimates - // for contract interactions can become inaccurate over time. This is because - // gas estimation is non-deterministic. The gas required for the exact same - // transaction call can change based on state of a contract or changes in the - // contracts environment (blockchain data or contracts it interacts with). - // Applying the 1.5 buffer has proven to be a useful guard against this non- - // deterministic behaviour. - // - // Gas estimation of simple sends should, however, be deterministic. As such - // no buffer is needed in those cases. - let bufferMultiplier = 1.5; - if (isSimpleSendOnNonStandardNetwork) { - bufferMultiplier = 1; - } else if (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]) { - bufferMultiplier = CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId]; - } - - try { - // call into the background process that will simulate transaction - // execution on the node and return an estimate of gasLimit - const estimatedGasLimit = await estimateGas(paramsForGasEstimate); - const estimateWithBuffer = addGasBuffer( - estimatedGasLimit, - blockGasLimit, - bufferMultiplier, - ); - return addHexPrefix(estimateWithBuffer); - } catch (error) { - const simulationFailed = - error.message.includes('Transaction execution error.') || - error.message.includes( - 'gas required exceeds allowance or always failing transaction', - ) || - (CHAIN_ID_TO_GAS_LIMIT_BUFFER_MAP[chainId] && - error.message.includes('gas required exceeds allowance')); - if (simulationFailed) { - const estimateWithBuffer = addGasBuffer( - paramsForGasEstimate?.gas ?? gasLimit, - blockGasLimit, - bufferMultiplier, - ); - return addHexPrefix(estimateWithBuffer); - } - throw error; - } -} - -export async function getERC20Balance(token, accountAddress) { - const contract = global.eth.contract(abi).at(token.address); - const usersToken = (await contract.balanceOf(accountAddress)) ?? null; - if (!usersToken) { - return '0x0'; - } - const amount = calcTokenAmount( - usersToken.balance.toString(), - token.decimals, - ).toString(16); - return addHexPrefix(amount); -} +const name = 'send'; // After modification of specific fields in specific circumstances we must // recompute the gasLimit estimate to be as accurate as possible. the cases @@ -463,25 +493,27 @@ export const computeEstimatedGasLimit = createAsyncThunk( async (_, thunkApi) => { const state = thunkApi.getState(); const { send, metamask } = state; + const draftTransaction = + send.draftTransactions[send.currentTransactionUUID]; const unapprovedTxs = getUnapprovedTxs(state); const isMultiLayerFeeNetwork = getIsMultiLayerFeeNetwork(state); - const transaction = unapprovedTxs[send.id]; + const transaction = unapprovedTxs[draftTransaction.id]; const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); - let layer1GasTotal; + let gasTotalForLayer1; if (isMultiLayerFeeNetwork) { - layer1GasTotal = await fetchEstimatedL1Fee(global.eth, { + gasTotalForLayer1 = await fetchEstimatedL1Fee(global.eth, { txParams: { - gasPrice: send.gas.gasPrice, - gas: send.gas.gasLimit, - to: send.recipient.address?.toLowerCase(), + gasPrice: draftTransaction.gas.gasPrice, + gas: draftTransaction.gas.gasLimit, + to: draftTransaction.recipient.address?.toLowerCase(), value: - send.amount.mode === 'MAX' - ? send.account.balance + send.amountMode === AMOUNT_MODES.MAX + ? send.selectedAccount.balance : send.amount.value, - from: send.account.address, - data: send.userInputHexData, + from: send.selectedAccount.address, + data: draftTransaction.userInputHexData, type: '0x0', }, }); @@ -493,21 +525,21 @@ export const computeEstimatedGasLimit = createAsyncThunk( !transaction.userEditedGasLimit ) { const gasLimit = await estimateGasLimitForSend({ - gasPrice: send.gas.gasPrice, + gasPrice: draftTransaction.gas.gasPrice, blockGasLimit: metamask.currentBlockGasLimit, selectedAddress: metamask.selectedAddress, - sendToken: send.asset.details, - to: send.recipient.address?.toLowerCase(), - value: send.amount.value, - data: send.userInputHexData, + sendToken: draftTransaction.asset.details, + to: draftTransaction.recipient.address?.toLowerCase(), + value: draftTransaction.amount.value, + data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, - gasLimit: send.gas.gasLimit, + gasLimit: draftTransaction.gas.gasLimit, }); await thunkApi.dispatch(setCustomGasLimit(gasLimit)); return { gasLimit, - layer1GasTotal, + gasTotalForLayer1, }; } return null; @@ -515,28 +547,18 @@ export const computeEstimatedGasLimit = createAsyncThunk( ); /** - * This method is used to keep the original logic from the gas.duck.js file - * after receiving a gasPrice from eth_gasPrice. First, the returned gasPrice - * was converted to GWEI, then it was converted to a Number, then in the send - * duck (here) we would use getGasPriceInHexWei to get back to hexWei. Now that - * we receive a GWEI estimate from the controller, we still need to do this - * weird conversion to get the proper rounding. - * - * @param {string} gasPriceEstimate - * @returns {string} + * @typedef {Object} Asset + * @property {AssetTypesString} type - The type of asset that the user + * is attempting to send. Defaults to 'NATIVE' which represents the native + * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. + * @property {string} balance - A hex string representing the balance + * that the user holds of the asset that they are attempting to send. + * @property {TokenDetails} [details] - An object that describes the + * selected asset in the case that the user is sending a token or collectibe. + * Will be null when asset.type is 'NATIVE'. + * @property {string} [error] - Error to display when there is an issue + * with the asset. */ -function getRoundedGasPrice(gasPriceEstimate) { - const gasPriceInDecGwei = conversionUtil(gasPriceEstimate, { - numberOfDecimals: 9, - toDenomination: GWEI, - fromNumericBase: 'dec', - toNumericBase: 'dec', - fromCurrency: ETH, - fromDenomination: GWEI, - }); - const gasPriceAsNumber = Number(gasPriceInDecGwei); - return getGasPriceInHexWei(gasPriceAsNumber); -} /** * Responsible for initializing required state for the send slice. @@ -550,34 +572,43 @@ function getRoundedGasPrice(gasPriceEstimate) { */ export const initializeSendState = createAsyncThunk( 'send/initializeSendState', - async (_, thunkApi) => { + async ({ chainHasChanged = false } = {}, thunkApi) => { + /** + * @typedef {Object} ReduxState + * @property {Object} metamask - Half baked type for the MetaMask object + * @property {SendState} send - the send state + */ + + /** + * @type {ReduxState} + */ const state = thunkApi.getState(); const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); const eip1559support = checkNetworkAndAccountSupports1559(state); - const { - send: { asset, stage, recipient, amount, userInputHexData }, - metamask, - } = state; + const account = getSelectedAccount(state); + const { send: sendState, metamask } = state; + const draftTransaction = + sendState.draftTransactions[sendState.currentTransactionUUID]; - // First determine the correct from address. For new sends this is always - // the currently selected account and switching accounts switches the from - // address. If editing an existing transaction (by clicking 'edit' on the - // send page), the fromAddress is always the address from the txParams. - const fromAddress = - stage === SEND_STAGES.EDIT - ? state.send.account.address - : metamask.selectedAddress; - // We need the account's balance which is calculated from cachedBalances in - // the getMetaMaskAccounts selector. getTargetAccount consumes this - // selector and returns the account at the specified address. - const account = getTargetAccount(state, fromAddress); + // If the draft transaction is not present, then this action has been + // dispatched out of sync with the intended flow. This is not always a bug. + // For instance, in the actions.js file we dispatch this action anytime the + // chain changes. + if (!draftTransaction) { + thunkApi.rejectWithValue( + 'draftTransaction not found, possibly not on send flow', + ); + } // Default gasPrice to 1 gwei if all estimation fails, this is only used // for gasLimit estimation and won't be set directly in state. Instead, we // will return the gasFeeEstimates and gasEstimateType so that the reducer // can set the appropriate gas fees in state. - let gasPrice = '0x1'; + let gasPrice = + sendState.stage === SEND_STAGES.EDIT + ? draftTransaction.gas.gasPrice + : '0x1'; let gasEstimatePollToken = null; // Instruct the background process that polling for gas prices should begin @@ -589,43 +620,49 @@ export const initializeSendState = createAsyncThunk( metamask: { gasFeeEstimates, gasEstimateType }, } = thunkApi.getState(); - // Because we are only interested in getting a gasLimit estimation we only - // need to worry about gasPrice. So we use maxFeePerGas as gasPrice if we - // have a fee market estimation. - if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { - gasPrice = getGasPriceInHexWei(gasFeeEstimates.medium); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { - gasPrice = getRoundedGasPrice(gasFeeEstimates.gasPrice); - } else if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - gasPrice = getGasPriceInHexWei( - gasFeeEstimates.medium.suggestedMaxFeePerGas, - ); - } else { - gasPrice = gasFeeEstimates.gasPrice - ? getRoundedGasPrice(gasFeeEstimates.gasPrice) - : '0x0'; + if (sendState.stage !== SEND_STAGES.EDIT) { + // Because we are only interested in getting a gasLimit estimation we only + // need to worry about gasPrice. So we use maxFeePerGas as gasPrice if we + // have a fee market estimation. + if (gasEstimateType === GAS_ESTIMATE_TYPES.LEGACY) { + gasPrice = getGasPriceInHexWei(gasFeeEstimates.medium); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE) { + gasPrice = getRoundedGasPrice(gasFeeEstimates.gasPrice); + } else if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { + gasPrice = getGasPriceInHexWei( + gasFeeEstimates.medium.suggestedMaxFeePerGas, + ); + } else { + gasPrice = gasFeeEstimates.gasPrice + ? getRoundedGasPrice(gasFeeEstimates.gasPrice) + : '0x0'; + } } // Set a basic gasLimit in the event that other estimation fails - let gasLimit = - asset.type === ASSET_TYPES.TOKEN || asset.type === ASSET_TYPES.COLLECTIBLE - ? GAS_LIMITS.BASE_TOKEN_ESTIMATE - : GAS_LIMITS.SIMPLE; + let { gasLimit } = draftTransaction.gas; if ( gasEstimateType !== GAS_ESTIMATE_TYPES.NONE && - stage !== SEND_STAGES.EDIT && - recipient.address + sendState.stage !== SEND_STAGES.EDIT && + draftTransaction.recipient.address ) { + gasLimit = + draftTransaction.asset.type === ASSET_TYPES.TOKEN || + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE + ? GAS_LIMITS.BASE_TOKEN_ESTIMATE + : GAS_LIMITS.SIMPLE; // Run our estimateGasLimit logic to get a more accurate estimation of // required gas. If this value isn't nullish, set it as the new gasLimit const estimatedGasLimit = await estimateGasLimitForSend({ gasPrice, blockGasLimit: metamask.currentBlockGasLimit, - selectedAddress: fromAddress, - sendToken: asset.details, - to: recipient.address.toLowerCase(), - value: amount.value, - data: userInputHexData, + selectedAddress: + draftTransaction.fromAccount?.address ?? + sendState.selectedAccount.address, + sendToken: draftTransaction.asset.details, + to: draftTransaction.recipient.address.toLowerCase(), + value: draftTransaction.amount.value, + data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, }); @@ -634,38 +671,11 @@ export const initializeSendState = createAsyncThunk( // We have to keep the gas slice in sync with the send slice state // so that it'll be initialized correctly if the gas modal is opened. await thunkApi.dispatch(setCustomGasLimit(gasLimit)); - // We must determine the balance of the asset that the transaction will be - // sending. This is done by referencing the native balance on the account - // for native assets, and calling the balanceOf method on the ERC20 - // contract for token sends. - let { balance } = account; - if (asset.type === ASSET_TYPES.TOKEN) { - if (asset.details === null) { - // If we're sending a token but details have not been provided we must - // abort and set the send slice into invalid status. - throw new Error( - 'Send slice initialized as token send without token details', - ); - } - 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, - assetBalance: balance, + account, chainId: getCurrentChainId(state), tokens: getTokens(state), + chainHasChanged, gasFeeEstimates, gasEstimateType, gasLimit, @@ -678,275 +688,187 @@ export const initializeSendState = createAsyncThunk( }, ); +// Action Payload Typedefs /** - * @typedef {Object} SendState - * @property {string} [id] - The id of a transaction that is being edited - * @property {SendStateStagesStrings} stage - The stage of the send flow that - * the user has progressed to. Defaults to 'INACTIVE' which results in the - * send screen not being shown. - * @property {SendStateStatusStrings} status - The status of the send slice - * which will be either 'VALID' or 'INVALID' - * @property {string} transactionType - Determines type of transaction being - * sent, defaulted to 0x0 (legacy). - * @property {boolean} eip1559support - tracks whether the current network - * supports EIP 1559 transactions. - * @property {Object} account - Details about the user's account. - * @property {string} [account.address] - from account address, defaults to - * selected account. will be the account the original transaction was sent - * from in the case of the EDIT stage. - * @property {string} [account.balance] - Hex string representing the balance - * of the from account. - * @property {string} [userInputHexData] - When a user has enabled custom hex - * data field in advanced options, they can supply data to the field which is - * stored under this key. - * @property {Object} gas - Details about the current gas settings - * @property {boolean} gas.isGasEstimateLoading - Indicates whether the gas - * estimate is loading. - * @property {string} [gas.gasEstimatePollToken] - String token identifying a - * listener for polling on the gasFeeController - * @property {boolean} gas.isCustomGasSet - true if the user set custom gas in - * the custom gas modal - * @property {string} gas.gasLimit - maximum gas needed for tx. - * @property {string} gas.gasPrice - price in wei to pay per gas. - * @property {string} gas.maxFeePerGas - Maximum price in wei to pay per gas. - * @property {string} gas.maxPriorityFeePerGas - Maximum priority fee in wei to - * pay per gas. - * @property {string} gas.gasPriceEstimate - Expected price in wei necessary to - * pay per gas used for a transaction to be included in a reasonable timeframe. - * Comes from the GasFeeController. - * @property {string} gas.gasTotal - maximum total price in wei to pay. - * @property {string} gas.minimumGasLimit - minimum supported gasLimit. - * @property {string} [gas.error] - error to display for gas fields. - * @property {Object} amount - An object containing information about the - * amount of currency to send. - * @property {SendStateAmountModeStrings} amount.mode - Describe whether the - * user has manually input an amount or if they have selected max to send the - * maximum amount of the selected currency. - * @property {string} amount.value - A hex string representing the amount of - * the selected currency to send. - * @property {string} [amount.error] - Error to display for the amount field. - * @property {Object} asset - An object that describes the asset that the user - * has selected to send. - * @property {AssetTypesString} asset.type - The type of asset that the user - * is attempting to send. Defaults to 'NATIVE' which represents the native - * asset of the chain. Can also be 'TOKEN' or 'COLLECTIBLE'. - * @property {string} asset.balance - A hex string representing the balance - * that the user holds of the asset that they are attempting to send. - * @property {Object} [asset.details] - An object that describes the selected - * asset in the case that the user is sending a token or collectibe. Will be - * null when asset.type is 'NATIVE'. - * @property {string} [asset.details.address] - The address of the selected - * 'TOKEN' or 'COLLECTIBLE' contract. - * @property {string} [asset.details.symbol] - The symbol of the selected - * asset. - * @property {number} [asset.details.decimals] - The number of decimals of the - * selected 'TOKEN' asset. - * @property {number} [asset.details.tokenId] - The id of the selected - * 'COLLECTIBLE' asset. - * @property {TokenStandardStrings} [asset.details.standard] - The standard - * of the selected 'TOKEN' or 'COLLECTIBLE' asset. - * @property {boolean} [asset.details.isERC721] - True when the asset is a - * ERC721 token. - * @property {string} [asset.error] - Error to display when there is an issue - * with the asset. - * @property {Object} recipient - An object that describes the intended - * recipient of the transaction. - * @property {SendStateRecipientModeStrings} recipient.mode - Describes which - * list of recipients the user is shown on the add recipient screen. When this - * key is set to 'MY_ACCOUNTS' the user is shown the list of accounts they - * own. When it is 'CONTACT_LIST' the user is shown the list of contacts they - * have saved in MetaMask and any addresses they have recently sent to. - * @property {string} recipient.address - The fully qualified address of the - * recipient. This is set after the recipient.userInput is validated, the - * userInput field is quickly updated to avoid delay between keystrokes and - * seeing the input field updated. After a debounc the address typed is - * validated and then the address field is updated. The address field is also - * set when the user selects a contact or account from the list, or an ENS - * resolution when typing ENS names. - * @property {string} recipient.userInput - The user input of the recipient - * which is updated quickly to avoid delays in the UI reflecting manual entry - * of addresses. - * @property {string} recipient.nickname - The nickname that the user has added - * to their address book for the recipient.address. - * @property {string} [recipient.error] - Error to display on the address field. - * @property {string} [recipient.warning] - Warning to display on the address - * field. - * @property {Object} multiLayerFees - An object containing attributes for use - * on chains that have layer 1 and layer 2 fees to consider for gas - * calculations. - * @property {string} multiLayerFees.layer1GasTotal - Layer 1 gas fee total on - * multi-layer fee networks - * @property {Array<{event: string, timestamp: number}>} history - An array of - * entries that describe the user's journey through the send flow. This is - * sent to the controller for attaching to state logs for troubleshooting and - * support. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} SimpleStringPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction> + * )} SendStateAmountModePayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} UpdateAssetPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction> + * > + * )} updateRecipientPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} UpdateRecipientModePayload */ /** - * @type {SendState} + * @typedef {Object} GasFeeUpdateParams + * @property {TransactionTypeString} transactionType - The transaction type + * @property {string} [maxFeePerGas] - The maximum amount in hex wei to pay + * per gas on a FEE_MARKET transaction. + * @property {string} [maxPriorityFeePerGas] - The maximum amount in hex + * wei to pay per gas as an incentive to miners on a FEE_MARKET + * transaction. + * @property {string} [gasPrice] - The amount in hex wei to pay per gas on + * a LEGACY transaction. + * @property {boolean} [isAutomaticUpdate] - true if the update is the + * result of a gas estimate update from the controller. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} GasFeeUpdatePayload */ -export const initialState = { - id: null, - stage: SEND_STAGES.INACTIVE, - status: SEND_STATUSES.VALID, - transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, - eip1559support: false, - account: { - address: null, - balance: '0x0', - }, - userInputHexData: null, - gas: { - isGasEstimateLoading: true, - gasEstimatePollToken: null, - isCustomGasSet: false, - gasLimit: '0x0', - gasPrice: '0x0', - maxFeePerGas: '0x0', - maxPriorityFeePerGas: '0x0', - gasPriceEstimate: '0x0', - gasTotal: '0x0', - minimumGasLimit: GAS_LIMITS.SIMPLE, - error: null, - }, - amount: { - mode: AMOUNT_MODES.INPUT, - value: '0x0', - error: null, - }, - asset: { - type: ASSET_TYPES.NATIVE, - balance: '0x0', - details: null, - error: null, - }, - recipient: { - mode: RECIPIENT_SEARCH_MODES.CONTACT_LIST, - userInput: '', - address: '', - nickname: '', - error: null, - warning: null, - }, - multiLayerFees: { - layer1GasTotal: '0x0', - }, - history: [], -}; /** - * Generates a txParams from the send slice. - * - * @param {SendState} state - the Send slice state - * @returns {import( - * '../../../shared/constants/transaction' - * ).TxParams} A txParams object that can be used to create a transaction or - * update an existing transaction. + * @typedef {Object} GasEstimateUpdateParams + * @property {GasEstimateType} gasEstimateType - The type of gas estimation + * provided by the controller. + * @property {( + * EthGasPriceEstimate | LegacyGasPriceEstimate | GasFeeEstimates + * )} gasFeeEstimates - The gas fee estimates provided by the controller. + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} GasEstimateUpdatePayload */ -function generateTransactionParams(state) { - const txParams = { - from: state.account.address, - // gasLimit always needs to be set regardless of the asset being sent - // or the type of transaction. - gas: state.gas.gasLimit, - }; - switch (state.asset.type) { - case ASSET_TYPES.TOKEN: - // 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. - txParams.to = state.asset.details.address; - txParams.value = '0x0'; - 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. - txParams.to = state.asset.details.address; - txParams.value = '0x0'; - 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 - // recipient and amount values and the data key is either null or - // populated with the user input provided in hex field. - txParams.to = state.recipient.address; - txParams.value = state.amount.value; - txParams.data = state.userInputHexData ?? undefined; - } - // We need to make sure that we only include the right gas fee fields - // based on the type of transaction the network supports. We will also set - // the type param here. - if (state.eip1559support) { - txParams.type = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; - - txParams.maxFeePerGas = state.gas.maxFeePerGas; - txParams.maxPriorityFeePerGas = state.gas.maxPriorityFeePerGas; - - if (!txParams.maxFeePerGas || txParams.maxFeePerGas === '0x0') { - txParams.maxFeePerGas = state.gas.gasPrice; - } - - if ( - !txParams.maxPriorityFeePerGas || - txParams.maxPriorityFeePerGas === '0x0' - ) { - txParams.maxPriorityFeePerGas = txParams.maxFeePerGas; - } - } else { - txParams.gasPrice = state.gas.gasPrice; - txParams.type = TRANSACTION_ENVELOPE_TYPES.LEGACY; - } - - return txParams; -} +/** + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} UpdateAssetPayload + * @typedef {( + * import('@reduxjs/toolkit').PayloadAction + * )} DraftTransactionPayload + */ const slice = createSlice({ name, initialState, reducers: { - addHistoryEntry: (state, action) => { - state.history.push({ - entry: action.payload, - timestamp: Date.now(), - }); - }, /** - * update current amount.value in state and run post update validation of - * the amount field and the send state. + * Adds a new draft transaction to state, first generating a new UUID for + * the transaction and setting that as the currentTransactionUUID. If the + * draft has an id property set, the stage is set to EDIT. * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction} action - The - * hex string to be set as the amount value. + * @param {DraftTransactionPayload} action - An action with payload that is + * a new draft transaction that will be added to state. + * @returns {void} */ - updateSendAmount: (state, action) => { - state.amount.value = addHexPrefix(action.payload); - // Once amount has changed, validate the field - slice.caseReducers.validateAmountField(state); - if (state.asset.type === ASSET_TYPES.NATIVE) { - // if sending the native asset the amount being sent will impact the - // gas field as well because the gas validation takes into - // consideration the available balance minus amount sent before - // checking if there is enough left to cover the gas fee. - slice.caseReducers.validateGasField(state); + addNewDraft: (state, action) => { + state.currentTransactionUUID = uuidv4(); + state.draftTransactions[state.currentTransactionUUID] = action.payload; + if (action.payload.id) { + state.stage = SEND_STAGES.EDIT; + } else { + state.stage = SEND_STAGES.ADD_RECIPIENT; } + }, + /** + * Adds an entry, with timestamp, to the draftTransaction history. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - An action with payload that is + * a string to be added to the history of the draftTransaction + * @returns {void} + */ + addHistoryEntry: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction) { + draftTransaction.history.push({ + entry: action.payload, + timestamp: Date.now(), + }); + } + }, + /** + * gasTotal is computed based on gasPrice and gasLimit and set in state + * recomputes the maximum amount if the current amount mode is 'MAX' and + * sending the native token. ERC20 assets max amount is unaffected by + * gasTotal so does not need to be recomputed. Finally, validates the gas + * field and send state. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + calculateGasTotal: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + // use maxFeePerGas as the multiplier if working with a FEE_MARKET transaction + // otherwise use gasPrice + if ( + draftTransaction.transactionType === + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET + ) { + draftTransaction.gas.gasTotal = addHexPrefix( + calcGasTotal( + draftTransaction.gas.gasLimit, + draftTransaction.gas.maxFeePerGas, + ), + ); + } else { + draftTransaction.gas.gasTotal = addHexPrefix( + calcGasTotal( + draftTransaction.gas.gasLimit, + draftTransaction.gas.gasPrice, + ), + ); + } + if ( + state.amountMode === AMOUNT_MODES.MAX && + draftTransaction.asset.type === ASSET_TYPES.NATIVE + ) { + slice.caseReducers.updateAmountToMax(state); + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); // validate send state slice.caseReducers.validateSendState(state); }, + /** + * Clears all drafts from send state and drops the currentTransactionUUID. + * This is an important first step before adding a new draft transaction to + * avoid possible collision. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + clearPreviousDrafts: (state) => { + state.currentTransactionUUID = null; + state.draftTransactions = {}; + }, + /** + * Clears the send state by setting it to the initial value + * + * @returns {SendState} + */ + resetSendState: () => initialState, + /** + * sets the amount mode to the provided value as long as it is one of the + * supported modes (MAX|INPUT) + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SendStateAmountModePayload} action - The amount mode + * to set the state to. + * @returns {void} + */ + updateAmountMode: (state, action) => { + if (Object.values(AMOUNT_MODES).includes(action.payload)) { + state.amountMode = action.payload; + } + }, /** * computes the maximum amount of asset that can be sent and then calls * the updateSendAmount action above with the computed value, which will @@ -954,25 +876,32 @@ const slice = createSlice({ * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. + * @returns {void} */ updateAmountToMax: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; let amount = '0x0'; - if (state.asset.type === ASSET_TYPES.TOKEN) { - const decimals = state.asset.details?.decimals ?? 0; + if (draftTransaction.asset.type === ASSET_TYPES.TOKEN) { + const decimals = draftTransaction.asset.details?.decimals ?? 0; const multiplier = Math.pow(10, Number(decimals)); - amount = multiplyCurrencies(state.asset.balance, multiplier, { - toNumericBase: 'hex', - multiplicandBase: 16, - multiplierBase: 10, - }); + amount = multiplyCurrencies( + draftTransaction.asset.balance, + multiplier, + { + toNumericBase: 'hex', + multiplicandBase: 16, + multiplierBase: 10, + }, + ); } else { const _gasTotal = sumHexes( - state.gas.gasTotal || '0x0', - state.multiLayerFees?.layer1GasTotal || '0x0', + draftTransaction.gas.gasTotal || '0x0', + state.gasTotalForLayer1 || '0x0', ); amount = subtractCurrencies( - addHexPrefix(state.asset.balance), + addHexPrefix(draftTransaction.asset.balance), addHexPrefix(_gasTotal), { toNumericBase: 'hex', @@ -986,176 +915,69 @@ const slice = createSlice({ }); }, /** - * updates the userInputHexData state key + * Updates the currently selected asset * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction} action - The - * hex string to be set as the userInputHexData value. + * @param {UpdateAssetPayload} action - The asest to set in the + * draftTransaction. + * @returns {void} */ - updateUserInputHexData: (state, action) => { - state.userInputHexData = action.payload; - }, - /** - * Transaction details of a previously created transaction that the user - * has selected to edit. - * - * @typedef {Object} EditTransactionPayload - * @property {string} gasLimit - The hex string maximum gas to use. - * @property {string} gasPrice - The amount in wei to pay for gas, in hex - * format. - * @property {string} amount - The amount of the currency to send, in hex - * format. - * @property {string} address - The address to send the transaction to. - * @property {string} [nickname] - The nickname the user has associated - * with the address in their contact book. - * @property {string} id - The id of the transaction in the - * TransactionController state[ - * @property {string} from - the address that the user is sending from - * @property {string} [data] - The hex data that describes the transaction. - * Used primarily for contract interactions, like token sends, but can - * also be provided by the user. - */ - /** - * Initiates the edit transaction flow by setting the stage to 'EDIT' and - * then pulling the details of the previously submitted transaction from - * the action payload. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction} action - The details of the - * transaction to be edited. - */ - editTransaction: (state, action) => { - state.stage = SEND_STAGES.EDIT; - state.gas.gasLimit = action.payload.gasLimit; - state.gas.gasPrice = action.payload.gasPrice; - state.amount.value = action.payload.amount; - state.gas.error = null; - state.amount.error = null; - state.asset.error = null; - state.recipient.address = action.payload.address; - state.recipient.nickname = action.payload.nickname; - state.id = action.payload.id; - state.account.address = action.payload.from; - state.userInputHexData = action.payload.data; - }, - /** - * gasTotal is computed based on gasPrice and gasLimit and set in state - * recomputes the maximum amount if the current amount mode is 'MAX' and - * sending the native token. ERC20 assets max amount is unaffected by - * gasTotal so does not need to be recomputed. Finally, validates the gas - * field and send state. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - */ - calculateGasTotal: (state) => { - // use maxFeePerGas as the multiplier if working with a FEE_MARKET transaction - // otherwise use gasPrice - if (state.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET) { - state.gas.gasTotal = addHexPrefix( - calcGasTotal(state.gas.gasLimit, state.gas.maxFeePerGas), - ); - } else { - state.gas.gasTotal = addHexPrefix( - calcGasTotal(state.gas.gasLimit, state.gas.gasPrice), - ); - } + updateAsset: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + + // If an asset update occurs that changes the type from 'NATIVE' to + // 'NATIVE' then this is likely the initial asset set of an edit + // transaction. We don't need to set the amount to zero in this case. + // The only times where an update would occur of this nature that we + // would want to set the amount to zero is on a network or account change + // but that update is handled elsewhere. + const skipAmountUpdate = + action.payload.type === ASSET_TYPES.NATIVE && + draftTransaction.asset.type === ASSET_TYPES.NATIVE; + draftTransaction.asset.type = action.payload.type; + draftTransaction.asset.balance = action.payload.balance; + draftTransaction.asset.error = action.payload.error; if ( - state.amount.mode === AMOUNT_MODES.MAX && - state.asset.type === ASSET_TYPES.NATIVE + draftTransaction.asset.type === ASSET_TYPES.TOKEN || + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ) { - slice.caseReducers.updateAmountToMax(state); + draftTransaction.asset.details = action.payload.details; + } else { + // clear the details object when sending native currency + draftTransaction.asset.details = null; + if (draftTransaction.recipient.error === CONTRACT_ADDRESS_ERROR) { + // Errors related to sending tokens to their own contract address + // 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. + if (state.amountMode === AMOUNT_MODES.MAX) { + slice.caseReducers.updateAmountToMax(state); + } else if (skipAmountUpdate === false) { + slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); } - slice.caseReducers.validateAmountField(state); - slice.caseReducers.validateGasField(state); // validate send state slice.caseReducers.validateSendState(state); }, - /** - * sets the provided gasLimit in state and then recomputes the gasTotal. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import('@reduxjs/toolkit').PayloadAction} action - The - * gasLimit in hex to set in state. - */ - updateGasLimit: (state, action) => { - state.gas.gasLimit = addHexPrefix(action.payload); - slice.caseReducers.calculateGasTotal(state); - }, - /** - * @typedef {Object} GasFeeUpdatePayload - * @property {TransactionTypeString} transactionType - The transaction type - * @property {string} [maxFeePerGas] - The maximum amount in hex wei to pay - * per gas on a FEE_MARKET transaction. - * @property {string} [maxPriorityFeePerGas] - The maximum amount in hex - * wei to pay per gas as an incentive to miners on a FEE_MARKET - * transaction. - * @property {string} [gasPrice] - The amount in hex wei to pay per gas on - * a LEGACY transaction. - * @property {boolean} [isAutomaticUpdate] - true if the update is the - * result of a gas estimate update from the controller. - */ - /** - * Sets the appropriate gas fees in state and determines and sets the - * appropriate transactionType based on gas fee fields received. - * - * @param {SendStateDraft} state - A writable draft of the send state to be - * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction} action - */ - updateGasFees: (state, action) => { - if ( - action.payload.transactionType === TRANSACTION_ENVELOPE_TYPES.FEE_MARKET - ) { - state.gas.maxFeePerGas = addHexPrefix(action.payload.maxFeePerGas); - state.gas.maxPriorityFeePerGas = addHexPrefix( - action.payload.maxPriorityFeePerGas, - ); - state.transactionType = TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; - } else { - // Until we remove the old UI we don't want to automatically update - // gasPrice if the user has already manually changed the field value. - // When receiving a new estimate the isAutomaticUpdate property will be - // on the payload (and set to true). If isAutomaticUpdate is true, - // then we check if the previous estimate was '0x0' or if the previous - // gasPrice equals the previous gasEstimate. if either of those cases - // are true then we update the gasPrice otherwise we skip it because - // it indicates the user has ejected from the estimates by modifying - // the field. - if ( - action.payload.isAutomaticUpdate !== true || - state.gas.gasPriceEstimate === '0x0' || - state.gas.gasPrice === state.gas.gasPriceEstimate - ) { - state.gas.gasPrice = addHexPrefix(action.payload.gasPrice); - } - state.transactionType = TRANSACTION_ENVELOPE_TYPES.LEGACY; - } - slice.caseReducers.calculateGasTotal(state); - }, - /** - * @typedef {Object} GasEstimateUpdatePayload - * @property {GasEstimateType} gasEstimateType - The type of gas estimation - * provided by the controller. - * @property {( - * EthGasPriceEstimate | LegacyGasPriceEstimate | GasFeeEstimates - * )} gasFeeEstimates - The gas fee estimates provided by the controller. - */ /** * Sets the appropriate gas fees in state after receiving new estimates. * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {( - * import('@reduxjs/toolkit').PayloadAction { const { gasFeeEstimates, gasEstimateType } = action.payload; @@ -1199,82 +1021,110 @@ const slice = createSlice({ break; } // Record the latest gasPriceEstimate for future comparisons - state.gas.gasPriceEstimate = addHexPrefix(gasPriceEstimate); + state.gasPriceEstimate = addHexPrefix(gasPriceEstimate); + }, + /** + * Sets the appropriate gas fees in state and determines and sets the + * appropriate transactionType based on gas fee fields received. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {GasFeeUpdatePayload} action - The gas fees to update with + * @returns {void} + */ + updateGasFees: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction) { + if ( + action.payload.transactionType === + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET + ) { + draftTransaction.gas.maxFeePerGas = addHexPrefix( + action.payload.maxFeePerGas, + ); + draftTransaction.gas.maxPriorityFeePerGas = addHexPrefix( + action.payload.maxPriorityFeePerGas, + ); + draftTransaction.transactionType = + TRANSACTION_ENVELOPE_TYPES.FEE_MARKET; + } else { + // Until we remove the old UI we don't want to automatically update + // gasPrice if the user has already manually changed the field value. + // When receiving a new estimate the isAutomaticUpdate property will be + // on the payload (and set to true). If isAutomaticUpdate is true, + // then we check if the previous estimate was '0x0' or if the previous + // gasPrice equals the previous gasEstimate. if either of those cases + // are true then we update the gasPrice otherwise we skip it because + // it indicates the user has ejected from the estimates by modifying + // the field. + if ( + action.payload.isAutomaticUpdate !== true || + state.gasPriceEstimate === '0x0' || + draftTransaction.gas.gasPrice === state.gasPriceEstimate + ) { + draftTransaction.gas.gasPrice = addHexPrefix( + action.payload.gasPrice, + ); + } + draftTransaction.transactionType = TRANSACTION_ENVELOPE_TYPES.LEGACY; + } + slice.caseReducers.calculateGasTotal(state); + } + }, + /** + * sets the provided gasLimit in state and then recomputes the gasTotal. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The + * gasLimit in hex to set in state. + * @returns {void} + */ + updateGasLimit: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.gas.gasLimit = addHexPrefix(action.payload); + slice.caseReducers.calculateGasTotal(state); }, /** * sets the layer 1 fees total (for a multi-layer fee network) * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import('@reduxjs/toolkit').PayloadAction} action - the - * layer1GasTotal to set in hex wei. + * @param {SimpleStringPayload} action - the + * gasTotalForLayer1 to set in hex wei. + * @returns {void} */ updateLayer1Fees: (state, action) => { - state.multiLayerFees.layer1GasTotal = action.payload; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + state.gasTotalForLayer1 = action.payload; if ( - state.amount.mode === AMOUNT_MODES.MAX && - state.asset.type === ASSET_TYPES.NATIVE + state.amountMode === AMOUNT_MODES.MAX && + draftTransaction.asset.type === ASSET_TYPES.NATIVE ) { slice.caseReducers.updateAmountToMax(state); } }, /** - * sets the amount mode to the provided value as long as it is one of the - * supported modes (MAX|INPUT) + * Updates the recipient of the draftTransaction * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. - * @param {import( - * '@reduxjs/toolkit' - * ).PayloadAction} action - The amount mode - * to set the state to. + * @param {updateRecipientPayload} action - The recipient to set in the + * draftTransaction. + * @returns {void} */ - updateAmountMode: (state, action) => { - if (Object.values(AMOUNT_MODES).includes(action.payload)) { - state.amount.mode = action.payload; - } - }, - updateAsset: (state, action) => { - state.asset.type = action.payload.type; - state.asset.balance = action.payload.balance; - state.asset.error = action.payload.error; - 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 - state.asset.details = null; - if (state.recipient.error === CONTRACT_ADDRESS_ERROR) { - // Errors related to sending tokens to their own contract address - // are no longer valid when sending native currency. - state.recipient.error = null; - } - - if (state.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING) { - // Warning related to sending tokens to a known contract address - // are no longer valid when sending native currency. - state.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. - if (state.amount.mode === AMOUNT_MODES.MAX) { - slice.caseReducers.updateAmountToMax(state); - } else { - slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); - } - // validate send state - slice.caseReducers.validateSendState(state); - }, updateRecipient: (state, action) => { - state.recipient.error = null; - state.recipient.userInput = ''; - state.recipient.address = action.payload.address ?? ''; - state.recipient.nickname = action.payload.nickname ?? ''; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.recipient.error = null; + state.recipientInput = ''; + draftTransaction.recipient.address = action.payload.address ?? ''; + draftTransaction.recipient.nickname = action.payload.nickname ?? ''; - if (state.recipient.address === '') { + if (draftTransaction.recipient.address === '') { // If address is null we are clearing the recipient and must return // to the ADD_RECIPIENT stage. state.stage = SEND_STAGES.ADD_RECIPIENT; @@ -1282,214 +1132,292 @@ const slice = createSlice({ // if an address is provided and an id exists, we progress to the EDIT // stage, otherwise we progress to the DRAFT stage. We also reset the // search mode for recipient search. - state.stage = state.id === null ? SEND_STAGES.DRAFT : SEND_STAGES.EDIT; - state.recipient.mode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; + state.stage = + draftTransaction.id === null ? SEND_STAGES.DRAFT : SEND_STAGES.EDIT; + state.recipientMode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; } // validate send state slice.caseReducers.validateSendState(state); }, - useDefaultGas: (state) => { - // Show the default gas price/limit fields in the send page - state.gas.isCustomGasSet = false; - }, - useCustomGas: (state) => { - // Show the gas fees set in the custom gas modal (state.gas.customData) - state.gas.isCustomGasSet = true; + /** + * Clears the user input and changes the recipient search mode to the + * specified value + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {UpdateRecipientModePayload} action - The mode to set the + * recipient search to + * @returns {void} + */ + updateRecipientSearchMode: (state, action) => { + state.recipientInput = ''; + state.recipientMode = action.payload; }, + /** + * Updates the value of the recipientInput key with what the user has + * typed into the recipient input field in the UI. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - the value the user has typed into + * the recipient field. + * @returns {void} + */ updateRecipientUserInput: (state, action) => { // Update the value in state to match what the user is typing into the // input field - state.recipient.userInput = action.payload; + state.recipientInput = action.payload; }, - validateRecipientUserInput: (state, action) => { - const { asset, recipient } = state; - - if ( - recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || - recipient.userInput === '' || - recipient.userInput === null - ) { - recipient.error = null; - recipient.warning = null; - } else { - const isSendingToken = - asset.type === ASSET_TYPES.TOKEN || - asset.type === ASSET_TYPES.COLLECTIBLE; - const { chainId, tokens, tokenAddressList } = action.payload; - if ( - isBurnAddress(recipient.userInput) || - (!isValidHexAddress(recipient.userInput, { - mixedCaseUseChecksum: true, - }) && - !isValidDomainName(recipient.userInput)) - ) { - recipient.error = isDefaultMetaMaskChain(chainId) - ? INVALID_RECIPIENT_ADDRESS_ERROR - : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; - } else if ( - isSendingToken && - isOriginContractAddress(recipient.userInput, asset.details.address) - ) { - recipient.error = CONTRACT_ADDRESS_ERROR; - } else { - recipient.error = null; - } - if ( - isSendingToken && - isValidHexAddress(recipient.userInput) && - (tokenAddressList.find((address) => - isEqualCaseInsensitive(address, recipient.userInput), - ) || - checkExistingAddresses(recipient.userInput, tokens)) - ) { - recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; - } else { - recipient.warning = null; - } + /** + * update current amount.value in state and run post update validation of + * the amount field and the send state. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The hex string to be set as the + * amount value. + * @returns {void} + */ + updateSendAmount: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.amount.value = addHexPrefix(action.payload); + // Once amount has changed, validate the field + slice.caseReducers.validateAmountField(state); + if (draftTransaction.asset.type === ASSET_TYPES.NATIVE) { + // if sending the native asset the amount being sent will impact the + // gas field as well because the gas validation takes into + // consideration the available balance minus amount sent before + // checking if there is enough left to cover the gas fee. + slice.caseReducers.validateGasField(state); } + // validate send state + slice.caseReducers.validateSendState(state); }, - updateRecipientSearchMode: (state, action) => { - state.recipient.userInput = ''; - state.recipient.mode = action.payload; + /** + * updates the userInputHexData state key + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @param {SimpleStringPayload} action - The hex string to be set as the + * userInputHexData value. + * @returns {void} + */ + updateUserInputHexData: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.userInputHexData = action.payload; }, - resetSendState: () => initialState, + /** + * Updates the gasIsSetInModal property to true which results in showing + * the gas fees from the custom gas modal in the send page. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + useCustomGas: (state) => { + state.gasIsSetInModal = true; + }, + /** + * Updates the gasIsSetInModal property to false which results in showing + * the default gas price/limit fields in the send page. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ + useDefaultGas: (state) => { + state.gasIsSetInModal = false; + }, + /** + * Checks for the validity of the draftTransactions selected amount to send + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ validateAmountField: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; switch (true) { // set error to INSUFFICIENT_FUNDS_ERROR if the account balance is lower // than the total price of the transaction inclusive of gas fees. - case state.asset.type === ASSET_TYPES.NATIVE && + case draftTransaction.asset.type === ASSET_TYPES.NATIVE && !isBalanceSufficient({ - amount: state.amount.value, - balance: state.asset.balance, - gasTotal: state.gas.gasTotal ?? '0x0', + amount: draftTransaction.amount.value, + balance: draftTransaction.asset.balance, + gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }): - state.amount.error = INSUFFICIENT_FUNDS_ERROR; + draftTransaction.amount.error = INSUFFICIENT_FUNDS_ERROR; break; // set error to INSUFFICIENT_FUNDS_ERROR if the token balance is lower // than the amount of token the user is attempting to send. - case state.asset.type === ASSET_TYPES.TOKEN && + case draftTransaction.asset.type === ASSET_TYPES.TOKEN && !isTokenBalanceSufficient({ - tokenBalance: state.asset.balance ?? '0x0', - amount: state.amount.value, - decimals: state.asset.details.decimals, + tokenBalance: draftTransaction.asset.balance ?? '0x0', + amount: draftTransaction.amount.value, + decimals: draftTransaction.asset.details.decimals, }): - state.amount.error = INSUFFICIENT_TOKENS_ERROR; + draftTransaction.amount.error = INSUFFICIENT_TOKENS_ERROR; break; // if the amount is negative, set error to NEGATIVE_ETH_ERROR // TODO: change this to NEGATIVE_ERROR and remove the currency bias. case conversionGreaterThan( { value: 0, fromNumericBase: 'dec' }, - { value: state.amount.value, fromNumericBase: 'hex' }, + { value: draftTransaction.amount.value, fromNumericBase: 'hex' }, ): - state.amount.error = NEGATIVE_ETH_ERROR; + draftTransaction.amount.error = NEGATIVE_ETH_ERROR; break; // If none of the above are true, set error to null default: - state.amount.error = null; + draftTransaction.amount.error = null; } }, + /** + * Checks if the user has enough funds to cover the cost of gas, always + * uses the native currency and does not take into account the amount + * being sent. If the user has enough to cover cost of gas but not gas + * + amount then the error will be displayed on the amount field. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ validateGasField: (state) => { - // Checks if the user has enough funds to cover the cost of gas, always - // uses the native currency and does not take into account the amount - // being sent. If the user has enough to cover cost of gas but not gas - // + amount then the error will be displayed on the amount field. + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; const insufficientFunds = !isBalanceSufficient({ amount: - state.asset.type === ASSET_TYPES.NATIVE ? state.amount.value : '0x0', - balance: state.account.balance, - gasTotal: state.gas.gasTotal ?? '0x0', + draftTransaction.asset.type === ASSET_TYPES.NATIVE + ? draftTransaction.amount.value + : '0x0', + balance: + draftTransaction.fromAccount?.balance ?? + state.selectedAccount.balance, + gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }); - state.gas.error = insufficientFunds ? INSUFFICIENT_FUNDS_ERROR : null; + draftTransaction.gas.error = insufficientFunds + ? INSUFFICIENT_FUNDS_ERROR + : null; }, + validateRecipientUserInput: (state, action) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + + if (draftTransaction) { + if ( + state.recipientMode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS || + state.recipientInput === '' || + state.recipientInput === null + ) { + 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; + if ( + isBurnAddress(state.recipientInput) || + (!isValidHexAddress(state.recipientInput, { + mixedCaseUseChecksum: true, + }) && + !isValidDomainName(state.recipientInput)) + ) { + draftTransaction.recipient.error = isDefaultMetaMaskChain(chainId) + ? INVALID_RECIPIENT_ADDRESS_ERROR + : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR; + } else if ( + isSendingToken && + isOriginContractAddress( + state.recipientInput, + draftTransaction.asset.details.address, + ) + ) { + draftTransaction.recipient.error = CONTRACT_ADDRESS_ERROR; + } else { + draftTransaction.recipient.error = null; + } + if ( + isSendingToken && + isValidHexAddress(state.recipientInput) && + (tokenAddressList.find((address) => + isEqualCaseInsensitive(address, state.recipientInput), + ) || + checkExistingAddresses(state.recipientInput, tokens)) + ) { + draftTransaction.recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; + } else { + draftTransaction.recipient.warning = null; + } + } + } + }, + /** + * Checks if the draftTransaction is currently valid. The following list of + * cases from the switch statement in this function describe when the + * transaction is invalid. Please keep this comment updated. + * + * case 1: State is invalid when amount field has an error. + * case 2: State is invalid when gas field has an error. + * case 3: State is invalid when asset field has an error. + * case 4: State is invalid if asset type is a token and the token details + * are unknown. + * case 5: State is invalid if no recipient has been added. + * case 6: State is invalid if the send state is uninitialized. + * case 7: State is invalid if gas estimates are loading. + * case 8: State is invalid if gasLimit is less than the gasLimitMinimum. + * + * @param {SendStateDraft} state - A writable draft of the send state to be + * updated. + * @returns {void} + */ validateSendState: (state) => { + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; switch (true) { - // 1 + 2. State is invalid when either gas or amount or asset fields have errors - // 3. State is invalid if asset type is a token and the token details - // are unknown. - // 4. State is invalid if no recipient has been added - // 5. State is invalid if the send state is uninitialized - // 6. State is invalid if gas estimates are loading - // 7. State is invalid if gasLimit is less than the minimumGasLimit - // 8. State is invalid if the selected asset is a ERC721 - case Boolean(state.amount.error): - case Boolean(state.gas.error): - case Boolean(state.asset.error): - case state.asset.type === ASSET_TYPES.TOKEN && - state.asset.details === null: + case Boolean(draftTransaction.amount.error): + case Boolean(draftTransaction.gas.error): + case Boolean(draftTransaction.asset.error): + case draftTransaction.asset.type === ASSET_TYPES.TOKEN && + draftTransaction.asset.details === null: case state.stage === SEND_STAGES.ADD_RECIPIENT: case state.stage === SEND_STAGES.INACTIVE: - case state.gas.isGasEstimateLoading: - case new BigNumber(state.gas.gasLimit, 16).lessThan( - new BigNumber(state.gas.minimumGasLimit), + case state.gasEstimateIsLoading: + case new BigNumber(draftTransaction.gas.gasLimit, 16).lessThan( + new BigNumber(state.gasLimitMinimum), ): - state.status = SEND_STATUSES.INVALID; + draftTransaction.status = SEND_STATUSES.INVALID; break; default: - state.status = SEND_STATUSES.VALID; + draftTransaction.status = SEND_STATUSES.VALID; } }, }, extraReducers: (builder) => { builder - .addCase(QR_CODE_DETECTED, (state, action) => { - // When data is received from the QR Code Scanner we set the recipient - // as long as a valid address can be pulled from the data. If an - // address is pulled but it is invalid, we display an error. - const qrCodeData = action.value; - if (qrCodeData) { - if (qrCodeData.type === 'address') { - const scannedAddress = qrCodeData.values.address.toLowerCase(); - if ( - isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) - ) { - if (state.recipient.address !== scannedAddress) { - slice.caseReducers.updateRecipient(state, { - payload: { address: scannedAddress }, - }); - } - } else { - state.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; - } - } - } - }) - .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { - // If we are on the edit flow the account we are keyed into will be the - // original 'from' account, which may differ from the selected account - if (state.stage !== SEND_STAGES.EDIT) { - // This event occurs when the user selects a new account from the - // account menu, or the currently active account's balance updates. - state.account.balance = action.payload.account.balance; - state.account.address = action.payload.account.address; - // We need to update the asset balance if the asset is the native - // network asset. Once we update the balance we recompute error state. - if (state.asset.type === ASSET_TYPES.NATIVE) { - state.asset.balance = action.payload.account.balance; - } - slice.caseReducers.validateAmountField(state); - slice.caseReducers.validateGasField(state); - slice.caseReducers.validateSendState(state); - } - }) .addCase(ACCOUNT_CHANGED, (state, action) => { // If we are on the edit flow then we need to watch for changes to the // current account.address in state and keep balance updated // appropriately if ( state.stage === SEND_STAGES.EDIT && - action.payload.account.address === state.account.address + action.payload.account.address === state.selectedAccount.address ) { // This event occurs when the user's account details update due to // background state changes. If the account that is being updated is // the current from account on the edit flow we need to update // the balance for the account and revalidate the send state. - state.account.balance = action.payload.account.balance; + state.selectedAccount.balance = action.payload.account.balance; // We need to update the asset balance if the asset is the native // network asset. Once we update the balance we recompute error state. - if (state.asset.type === ASSET_TYPES.NATIVE) { - state.asset.balance = action.payload.account.balance; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (draftTransaction?.asset.type === ASSET_TYPES.NATIVE) { + draftTransaction.asset.balance = action.payload.account.balance; } slice.caseReducers.validateAmountField(state); slice.caseReducers.validateGasField(state); @@ -1501,35 +1429,87 @@ const slice = createSlice({ // to check to see if an entry exists for the current address or if the // entry changed. const { addressBook } = action.payload; - if (addressBook[state.recipient.address]?.name) { - state.recipient.nickname = addressBook[state.recipient.address].name; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if ( + draftTransaction && + addressBook[draftTransaction.recipient.address]?.name + ) { + draftTransaction.recipient.nickname = + addressBook[draftTransaction.recipient.address].name; } }) + .addCase(computeEstimatedGasLimit.pending, (state) => { + // When we begin to fetch gasLimit we should indicate we are loading + // a gas estimate. + state.gasEstimateIsLoading = true; + }) + .addCase(computeEstimatedGasLimit.fulfilled, (state, action) => { + // When we receive a new gasLimit from the computeEstimatedGasLimit + // thunk we need to update our gasLimit in the slice. We call into the + // caseReducer updateGasLimit to tap into the appropriate follow up + // checks and gasTotal calculation. First set gasEstimateIsLoading to + // false. + state.gasEstimateIsLoading = false; + if (action.payload?.gasLimit) { + slice.caseReducers.updateGasLimit(state, { + payload: action.payload.gasLimit, + }); + } + if (action.payload?.gasTotalForLayer1) { + slice.caseReducers.updateLayer1Fees(state, { + payload: action.payload.gasTotalForLayer1, + }); + } + }) + .addCase(computeEstimatedGasLimit.rejected, (state) => { + // If gas estimation fails, we should set the loading state to false, + // because it is no longer loading + state.gasEstimateIsLoading = false; + }) + .addCase(GAS_FEE_ESTIMATES_UPDATED, (state, action) => { + // When the gasFeeController updates its gas fee estimates we need to + // update and validate state based on those new values + slice.caseReducers.updateGasFeeEstimates(state, { + payload: action.payload, + }); + }) .addCase(initializeSendState.pending, (state) => { // when we begin initializing state, which can happen when switching - // chains even after loading the send flow, we set - // gas.isGasEstimateLoading as initialization will trigger a fetch - // for gasPrice estimates. - state.gas.isGasEstimateLoading = true; + // chains even after loading the send flow, we set gasEstimateIsLoading + // as initialization will trigger a fetch for gasPrice estimates. + state.gasEstimateIsLoading = true; }) .addCase(initializeSendState.fulfilled, (state, action) => { // writes the computed initialized state values into the slice and then // calculates slice validity using the caseReducers. state.eip1559support = action.payload.eip1559support; - state.account.address = action.payload.address; - state.account.balance = action.payload.nativeBalance; - state.asset.balance = action.payload.assetBalance; - state.gas.gasLimit = action.payload.gasLimit; + state.selectedAccount.address = action.payload.account.address; + state.selectedAccount.balance = action.payload.account.balance; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + draftTransaction.gas.gasLimit = action.payload.gasLimit; slice.caseReducers.updateGasFeeEstimates(state, { payload: { gasFeeEstimates: action.payload.gasFeeEstimates, gasEstimateType: action.payload.gasEstimateType, }, }); - state.gas.gasTotal = action.payload.gasTotal; - state.gas.gasEstimatePollToken = action.payload.gasEstimatePollToken; + draftTransaction.gas.gasTotal = action.payload.gasTotal; + state.gasEstimatePollToken = action.payload.gasEstimatePollToken; + if (action.payload.chainHasChanged) { + // If the state was reinitialized as a result of the user changing + // the network from the network dropdown, then the selected asset is + // no longer valid and should be set to the native asset for the + // network. + draftTransaction.asset.type = ASSET_TYPES.NATIVE; + draftTransaction.asset.balance = + draftTransaction.fromAccount?.balance ?? + state.selectedAccount.balance; + draftTransaction.asset.details = null; + } if (action.payload.gasEstimatePollToken) { - state.gas.isGasEstimateLoading = false; + state.gasEstimateIsLoading = false; } if (state.stage !== SEND_STAGES.INACTIVE) { slice.caseReducers.validateRecipientUserInput(state, { @@ -1541,48 +1521,59 @@ const slice = createSlice({ }, }); } - state.stage = - state.stage === SEND_STAGES.INACTIVE - ? SEND_STAGES.ADD_RECIPIENT - : state.stage; slice.caseReducers.validateAmountField(state); slice.caseReducers.validateGasField(state); slice.caseReducers.validateSendState(state); }) - .addCase(computeEstimatedGasLimit.pending, (state) => { - // When we begin to fetch gasLimit we should indicate we are loading - // a gas estimate. - state.gas.isGasEstimateLoading = true; - }) - .addCase(computeEstimatedGasLimit.fulfilled, (state, action) => { - // When we receive a new gasLimit from the computeEstimatedGasLimit - // thunk we need to update our gasLimit in the slice. We call into the - // caseReducer updateGasLimit to tap into the appropriate follow up - // checks and gasTotal calculation. First set isGasEstimateLoading to - // false. - state.gas.isGasEstimateLoading = false; - if (action.payload?.gasLimit) { - slice.caseReducers.updateGasLimit(state, { - payload: action.payload.gasLimit, - }); - } - if (action.payload?.layer1GasTotal) { - slice.caseReducers.updateLayer1Fees(state, { - payload: action.payload.layer1GasTotal, - }); + .addCase(SELECTED_ACCOUNT_CHANGED, (state, action) => { + // If we are on the edit flow the account we are keyed into will be the + // original 'from' account, which may differ from the selected account + if (state.stage !== SEND_STAGES.EDIT) { + // This event occurs when the user selects a new account from the + // account menu, or the currently active account's balance updates. + state.selectedAccount.balance = action.payload.account.balance; + state.selectedAccount.address = action.payload.account.address; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + // This action will occur even when we aren't on the send flow, which + // is okay as it keeps the selectedAccount details up to date. We do + // not need to validate anything if there isn't a current draft + // transaction. If there is, we need to update the asset balance if + // the asset is set to the native network asset, and then validate + // the transaction. + if (draftTransaction) { + if (draftTransaction?.asset.type === ASSET_TYPES.NATIVE) { + draftTransaction.asset.balance = action.payload.account.balance; + } + slice.caseReducers.validateAmountField(state); + slice.caseReducers.validateGasField(state); + slice.caseReducers.validateSendState(state); + } } }) - .addCase(computeEstimatedGasLimit.rejected, (state) => { - // If gas estimation fails, we should set the loading state to false, - // because it is no longer loading - state.gas.isGasEstimateLoading = false; - }) - .addCase(GAS_FEE_ESTIMATES_UPDATED, (state, action) => { - // When the gasFeeController updates its gas fee estimates we need to - // update and validate state based on those new values - slice.caseReducers.updateGasFeeEstimates(state, { - payload: action.payload, - }); + .addCase(QR_CODE_DETECTED, (state, action) => { + // When data is received from the QR Code Scanner we set the recipient + // as long as a valid address can be pulled from the data. If an + // address is pulled but it is invalid, we display an error. + const qrCodeData = action.value; + const draftTransaction = + state.draftTransactions[state.currentTransactionUUID]; + if (qrCodeData && draftTransaction) { + if (qrCodeData.type === 'address') { + const scannedAddress = qrCodeData.values.address.toLowerCase(); + if ( + isValidHexAddress(scannedAddress, { allowNonPrefixed: false }) + ) { + if (draftTransaction.recipient.address !== scannedAddress) { + slice.caseReducers.updateRecipient(state, { + payload: { address: scannedAddress }, + }); + } + } else { + draftTransaction.recipient.error = INVALID_RECIPIENT_ADDRESS_ERROR; + } + } + } }); }, }); @@ -1604,6 +1595,140 @@ export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; // Action Creators +/** + * This method is for usage when validating user input so that validation + * is only run after a delay in typing of 300ms. Usage at callsites requires + * 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); + +/** + * Begins a new draft transaction, derived from the txParams of an existing + * transaction in the TransactionController. This action will first clear out + * the previous draft transactions and currentTransactionUUID from state. This + * action is one of the two entry points into the send flow. NOTE: You must + * route to the send page *after* dispatching this action resolves to ensure + * that the draftTransaction is properly created. + * + * @param {AssetTypesString} assetType - The type of asset the transaction + * being edited was sending. The details of the asset will be retrieved from + * the transaction data in state. + * @param {string} transactionId - The id of the transaction being edited. + * @returns {ThunkAction} + */ +export function editExistingTransaction(assetType, transactionId) { + return async (dispatch, getState) => { + await dispatch(actions.clearPreviousDrafts()); + const state = getState(); + const unapprovedTransactions = getUnapprovedTxs(state); + const transaction = unapprovedTransactions[transactionId]; + const account = getTargetAccount(state, transaction.txParams.from); + + if (assetType === ASSET_TYPES.NATIVE) { + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + id: transactionId, + fromAccount: account, + gas: { + ...draftTransactionInitialState.gas, + gasLimit: transaction.txParams.gas, + gasPrice: transaction.txParams.gasPrice, + }, + userInputHexData: transaction.txParams.data, + recipient: { + ...draftTransactionInitialState.recipient, + address: transaction.txParams.to, + nickname: + getAddressBookEntryOrAccountName(state, transaction.txParams.to) + ?.name ?? '', + }, + amount: { + ...draftTransactionInitialState.amount, + value: transaction.txParams.value, + }, + history: [ + `sendFlow - user clicked edit on transaction with id ${transactionId}`, + ], + }), + ); + await dispatch( + updateSendAsset( + { type: ASSET_TYPES.NATIVE }, + { skipComputeEstimatedGasLimit: true }, + ), + ); + } else { + const tokenData = parseStandardTokenTransactionData( + transaction.txParams.data, + ); + const tokenAmountInDec = + assetType === ASSET_TYPES.TOKEN ? getTokenValueParam(tokenData) : '1'; + const address = getTokenAddressParam(tokenData); + const nickname = + getAddressBookEntryOrAccountName(state, address)?.name ?? ''; + + const tokenAmountInHex = addHexPrefix( + conversionUtil(tokenAmountInDec, { + fromNumericBase: 'dec', + toNumericBase: 'hex', + }), + ); + + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + id: transactionId, + fromAccount: account, + gas: { + ...draftTransactionInitialState.gas, + gasLimit: transaction.txParams.gas, + gasPrice: transaction.txParams.gasPrice, + }, + userInputHexData: transaction.txParams.data, + recipient: { + ...draftTransactionInitialState.recipient, + address, + nickname, + }, + amount: { + ...draftTransactionInitialState.amount, + value: tokenAmountInHex, + }, + history: [ + `sendFlow - user clicked edit on transaction with id ${transactionId}`, + ], + }), + ); + + await dispatch( + updateSendAsset( + { + type: assetType, + details: { + address: transaction.txParams.to, + ...(assetType === ASSET_TYPES.COLLECTIBLE + ? { tokenId: getTokenValueParam(tokenData) } + : {}), + }, + }, + { skipComputeEstimatedGasLimit: true }, + ), + ); + } + + await dispatch(initializeSendState()); + }; +} + /** * This method is a temporary placeholder to support the old UI in both the * gas modal and the send flow. Soon we won't need to modify gasPrice from the @@ -1614,6 +1739,7 @@ export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry }; * * @deprecated - don't extend the usage of this temporary method * @param {string} gasPrice - new gas price in hex wei + * @returns {ThunkAction} */ export function updateGasPrice(gasPrice) { return (dispatch) => { @@ -1629,207 +1755,40 @@ export function updateGasPrice(gasPrice) { }; } -export function resetSendState() { - return async (dispatch, getState) => { - const state = getState(); - dispatch(actions.resetSendState()); - - if (state[name].gas.gasEstimatePollToken) { - await disconnectGasFeeEstimatePoller( - state[name].gas.gasEstimatePollToken, - ); - removePollingTokenFromAppState(state[name].gas.gasEstimatePollToken); - } - }; -} /** - * Updates the amount the user intends to send and performs side effects. - * 1. If the current mode is MAX change to INPUT - * 2. If sending a token, recompute the gasLimit estimate + * Updates the recipient in state based on the input provided, and then will + * recompute gas limit when sending a TOKEN asset type. Changing the recipient + * address results in hex data changing because the recipient address is + * encoded in the data instead of being in the 'to' field. The to field in a + * token send will always be the token contract address. + * If no nickname is provided, the address book state will be checked to see if + * a nickname for the passed address has already been saved. This ensures the + * (temporary) send state recipient nickname is consistent with the address book + * nickname which has already been persisted to state. * - * @param {string} amount - hex string representing value + * @param {Object} recipient - Recipient information + * @param {string} recipient.address - hex address to send the transaction to + * @param {string} [recipient.nickname] - Alias for the address to display + * to the user + * @returns {ThunkAction} */ -export function updateSendAmount(amount) { +export function updateRecipient({ address, nickname }) { return async (dispatch, getState) => { + // Do not addHistoryEntry here as this is called from a number of places + // each with significance to the user and transaction history. const state = getState(); - const { metamask } = state; - let logAmount = amount; - if (state[name].asset.type === ASSET_TYPES.TOKEN) { - const multiplier = Math.pow( - 10, - Number(state[name].asset.details?.decimals || 0), - ); - const decimalValueString = conversionUtil(addHexPrefix(amount), { - fromNumericBase: 'hex', - toNumericBase: 'dec', - toCurrency: state[name].asset.details?.symbol, - conversionRate: multiplier, - invertConversionRate: true, - }); - - logAmount = `${Number(decimalValueString) ? decimalValueString : ''} ${ - state[name].asset.details?.symbol - }`; - } else { - const ethValue = getValueFromWeiHex({ - value: amount, - toCurrency: ETH, - numberOfDecimals: 8, - }); - logAmount = `${ethValue} ${metamask?.provider?.ticker || ETH}`; - } + const nicknameFromAddressBookEntryOrAccountName = + getAddressBookEntryOrAccountName(state, address) ?? ''; await dispatch( - addHistoryEntry(`sendFlow - user set amount to ${logAmount}`), + actions.updateRecipient({ + address, + nickname: nickname || nicknameFromAddressBookEntryOrAccountName, + }), ); - await dispatch(actions.updateSendAmount(amount)); - if (state.send.amount.mode === AMOUNT_MODES.MAX) { - await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); - } await dispatch(computeEstimatedGasLimit()); }; } -/** - * Defines the shape for the details input parameter for updateSendAsset - * - * @typedef {Object} TokenDetails - * @property {string} address - The contract address for the ERC20 token. - * @property {string} decimals - The number of token decimals. - * @property {string} symbol - The asset symbol to display. - */ - -/** - * updates the asset to send to one of NATIVE or TOKEN and ensures that the - * asset balance is set. If sending a TOKEN also updates the asset details - * object with the appropriate ERC20 details including address, symbol and - * decimals. - * - * @param {Object} payload - action payload - * @param {string} payload.type - type of asset to send - * @param {TokenDetails} [payload.details] - ERC20 details if sending TOKEN asset - */ -export function updateSendAsset({ type, details }) { - return async (dispatch, getState) => { - dispatch(addHistoryEntry(`sendFlow - user set asset type to ${type}`)); - dispatch( - addHistoryEntry( - `sendFlow - user set asset symbol to ${details?.symbol ?? 'undefined'}`, - ), - ); - dispatch( - addHistoryEntry( - `sendFlow - user set asset address to ${ - details?.address ?? 'undefined' - }`, - ), - ); - const state = getState(); - let { balance, error } = state.send.asset; - const userAddress = state.send.account.address ?? getSelectedAddress(state); - if (type === ASSET_TYPES.TOKEN) { - if (details) { - if (details.standard === undefined) { - await dispatch(showLoadingIndication()); - const { standard } = await getTokenStandardAndDetails( - details.address, - userAddress, - ); - if ( - process.env.COLLECTIBLES_V1 && - (standard === TOKEN_STANDARDS.ERC721 || - standard === TOKEN_STANDARDS.ERC1155) - ) { - await dispatch(hideLoadingIndication()); - dispatch( - showModal({ - name: 'CONVERT_TOKEN_TO_NFT', - tokenAddress: details.address, - }), - ); - error = INVALID_ASSET_TYPE; - throw new Error(error); - } - details.standard = standard; - } - - // if changing to a token, get the balance from the network. The asset - // overview page and asset list on the wallet overview page contain - // send buttons that call this method before initialization occurs. - // When this happens we don't yet have an account.address so default to - // the currently active account. In addition its possible for the balance - // check to take a decent amount of time, so we display a loading - // indication so that that immediate feedback is displayed to the user. - if (details.standard === TOKEN_STANDARDS.ERC20) { - error = null; - balance = await getERC20Balance(details, userAddress); - } - await dispatch(hideLoadingIndication()); - } - } else if (type === ASSET_TYPES.COLLECTIBLE) { - let isCurrentOwner = true; - try { - isCurrentOwner = await isCollectibleOwner( - getSelectedAddress(state), - details.address, - details.tokenId, - ); - } catch (err) { - if (err.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(err.message)); - } - } - - if (details.standard === undefined) { - const { standard } = await getTokenStandardAndDetails( - details.address, - userAddress, - ); - details.standard = standard; - } - - if (details.standard === TOKEN_STANDARDS.ERC1155) { - throw new Error('Sends of ERC1155 tokens are not currently supported'); - } - - if (isCurrentOwner) { - error = null; - balance = '0x1'; - } else { - throw new Error( - 'Send slice initialized as collectible send with a collectible not currently owned by the select account', - ); - } - } else { - error = null; - // if changing to native currency, get it from the account key in send - // state which is kept in sync when accounts change. - balance = state.send.account.balance; - } - // update the asset in state which will re-run amount and gas validation - await dispatch(actions.updateAsset({ type, details, balance, error })); - await dispatch(computeEstimatedGasLimit()); - }; -} - -/** - * This method is for usage when validating user input so that validation - * is only run after a delay in typing of 300ms. Usage at callsites requires - * 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); - /** * This method is called to update the user's input into the ENS input field. * Once the field is updated, the field will be validated using a debounced @@ -1856,71 +1815,180 @@ export function updateRecipientUserInput(userInput) { }; } -export function useContactListForRecipientSearch() { - return (dispatch) => { - dispatch( - addHistoryEntry( - `sendFlow - user selected back to all on recipient screen`, - ), - ); - dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); - }; -} - -export function useMyAccountsForRecipientSearch() { - return (dispatch) => { - dispatch( - addHistoryEntry( - `sendFlow - user selected transfer to my accounts on recipient screen`, - ), - ); - dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); - }; -} - /** - * Updates the recipient in state based on the input provided, and then will - * recompute gas limit when sending a TOKEN asset type. Changing the recipient - * address results in hex data changing because the recipient address is - * encoded in the data instead of being in the 'to' field. The to field in a - * token send will always be the token contract address. - * If no nickname is provided, the address book state will be checked to see if - * a nickname for the passed address has already been saved. This ensures the - * (temporary) send state recipient nickname is consistent with the address book - * nickname which has already been persisted to state. + * Updates the amount the user intends to send and performs side effects. + * 1. If the current mode is MAX change to INPUT + * 2. If sending a token, recompute the gasLimit estimate * - * @param {Object} recipient - Recipient information - * @param {string} recipient.address - hex address to send the transaction to - * @param {string} [recipient.nickname] - Alias for the address to display - * to the user + * @param {string} amount - hex string representing value + * @returns {ThunkAction} */ -export function updateRecipient({ address, nickname }) { +export function updateSendAmount(amount) { return async (dispatch, getState) => { - // Do not addHistoryEntry here as this is called from a number of places - // each with significance to the user and transaction history. const state = getState(); - const nicknameFromAddressBookEntryOrAccountName = - getAddressBookEntryOrAccountName(state, address) ?? ''; + const { metamask } = state; + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + let logAmount = amount; + if (draftTransaction.asset.type === ASSET_TYPES.TOKEN) { + const multiplier = Math.pow( + 10, + Number(draftTransaction.asset.details?.decimals || 0), + ); + const decimalValueString = conversionUtil(addHexPrefix(amount), { + fromNumericBase: 'hex', + toNumericBase: 'dec', + toCurrency: draftTransaction.asset.details?.symbol, + conversionRate: multiplier, + invertConversionRate: true, + }); + + logAmount = `${Number(decimalValueString) ? decimalValueString : ''} ${ + draftTransaction.asset.details?.symbol + }`; + } else { + const ethValue = getValueFromWeiHex({ + value: amount, + toCurrency: ETH, + numberOfDecimals: 8, + }); + logAmount = `${ethValue} ${metamask?.provider?.ticker || ETH}`; + } await dispatch( - actions.updateRecipient({ - address, - nickname: nickname || nicknameFromAddressBookEntryOrAccountName, - }), + addHistoryEntry(`sendFlow - user set amount to ${logAmount}`), ); + await dispatch(actions.updateSendAmount(amount)); + if (state[name].amountMode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + } await dispatch(computeEstimatedGasLimit()); }; } /** - * Clears out the recipient user input, ENS resolution and recipient validation. + * updates the asset to send to one of NATIVE or TOKEN and ensures that the + * asset balance is set. If sending a TOKEN also updates the asset details + * object with the appropriate ERC20 details including address, symbol and + * decimals. + * + * @param {Object} payload - action payload + * @param {string} payload.type - type of asset to send + * @param {TokenDetails} [payload.details] - ERC20 details if sending TOKEN asset + * @returns {ThunkAction} */ -export function resetRecipientInput() { - return async (dispatch) => { - await dispatch(addHistoryEntry(`sendFlow - user cleared recipient input`)); - await dispatch(updateRecipientUserInput('')); - await dispatch(updateRecipient({ address: '', nickname: '' })); - await dispatch(resetEnsResolution()); - await dispatch(validateRecipientUserInput()); +export function updateSendAsset( + { type, details: providedDetails }, + { skipComputeEstimatedGasLimit = false } = {}, +) { + return async (dispatch, getState) => { + const state = getState(); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + const sendingAddress = + draftTransaction.fromAccount?.address ?? + state[name].selectedAccount.address ?? + getSelectedAddress(state); + const account = getTargetAccount(state, sendingAddress); + if (type === ASSET_TYPES.NATIVE) { + await dispatch( + addHistoryEntry( + `sendFlow - user set asset of type ${ + ASSET_TYPES.NATIVE + } with symbol ${state.metamask.provider?.ticker ?? ETH}`, + ), + ); + await dispatch( + actions.updateAsset({ + type, + details: null, + balance: account.balance, + error: null, + }), + ); + } else { + await dispatch(showLoadingIndication()); + const details = { + ...providedDetails, + ...(await getTokenStandardAndDetails( + providedDetails.address, + sendingAddress, + providedDetails.tokenId, + )), + }; + await dispatch(hideLoadingIndication()); + const balance = addHexPrefix( + calcTokenAmount(details.balance, details.decimals).toString(16), + ); + const asset = { + type, + details, + balance, + error: null, + }; + if ( + details.standard === TOKEN_STANDARDS.ERC1155 && + type === ASSET_TYPES.COLLECTIBLE + ) { + throw new Error('Sends of ERC1155 tokens are not currently supported'); + } else if ( + details.standard === TOKEN_STANDARDS.ERC1155 || + details.standard === TOKEN_STANDARDS.ERC721 + ) { + if (type === ASSET_TYPES.TOKEN && process.env.COLLECTIBLES_V1) { + dispatch( + showModal({ + name: 'CONVERT_TOKEN_TO_NFT', + tokenAddress: details.address, + }), + ); + asset.error = INVALID_ASSET_TYPE; + throw new Error(INVALID_ASSET_TYPE); + } else { + let isCurrentOwner = true; + try { + isCurrentOwner = await isCollectibleOwner( + sendingAddress, + details.address, + details.tokenId, + ); + } catch (err) { + if (err.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(err.message)); + } + } + + if (isCurrentOwner) { + asset.error = null; + asset.balance = '0x1'; + } else { + throw new Error( + 'Send slice initialized as collectible send with a collectible not currently owned by the select account', + ); + } + await dispatch( + addHistoryEntry( + `sendFlow - user set asset to NFT with tokenId ${details.tokenId} and address ${details.address}`, + ), + ); + } + } else { + await dispatch( + addHistoryEntry( + `sendFlow - user set asset to ERC20 token with symbol ${details.symbol} and address ${details.address}`, + ), + ); + // do nothing extra. + } + + await dispatch(actions.updateAsset(asset)); + } + if (skipComputeEstimatedGasLimit === false) { + await dispatch(computeEstimatedGasLimit()); + } }; } @@ -1933,6 +2001,7 @@ export function resetRecipientInput() { * recipient and value, NOT what the user has supplied. * * @param {string} hexData - hex encoded string representing transaction data. + * @returns {ThunkAction} */ export function updateSendHexData(hexData) { return async (dispatch, getState) => { @@ -1941,31 +2010,77 @@ export function updateSendHexData(hexData) { ); await dispatch(actions.updateUserInputHexData(hexData)); const state = getState(); - if (state.send.asset.type === ASSET_TYPES.NATIVE) { + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; + if (draftTransaction.asset.type === ASSET_TYPES.NATIVE) { await dispatch(computeEstimatedGasLimit()); } }; } /** - * Toggles the amount.mode between INPUT and MAX modes. - * As a result, the amount.value will change to either '0x0' when moving from - * MAX to INPUT, or to the maximum allowable amount based on current asset when - * moving from INPUT to MAX. + * Sets the recipient search mode to show a list of the user's contacts and + * recently interacted with addresses. + * + * @returns {ThunkAction} */ -export function toggleSendMaxMode() { +export function useContactListForRecipientSearch() { + return (dispatch) => { + dispatch( + addHistoryEntry( + `sendFlow - user selected back to all on recipient screen`, + ), + ); + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.CONTACT_LIST)); + }; +} + +/** + * Sets the recipient search mode to show a list of the user's own accounts. + * + * @returns {ThunkAction} + */ +export function useMyAccountsForRecipientSearch() { + return (dispatch) => { + dispatch( + addHistoryEntry( + `sendFlow - user selected transfer to my accounts on recipient screen`, + ), + ); + dispatch(updateRecipientSearchMode(RECIPIENT_SEARCH_MODES.MY_ACCOUNTS)); + }; +} + +/** + * Clears out the recipient user input, ENS resolution and recipient validation. + * + * @returns {ThunkAction} + */ +export function resetRecipientInput() { + return async (dispatch) => { + await dispatch(addHistoryEntry(`sendFlow - user cleared recipient input`)); + await dispatch(updateRecipientUserInput('')); + await dispatch(updateRecipient({ address: '', nickname: '' })); + await dispatch(resetEnsResolution()); + await dispatch(validateRecipientUserInput()); + }; +} + +/** + * Resets the entire send state tree to the initial state. It also disconnects + * polling from the gas controller if the token is present in state. + * + * @returns {ThunkAction} + */ +export function resetSendState() { return async (dispatch, getState) => { const state = getState(); - if (state.send.amount.mode === AMOUNT_MODES.MAX) { - await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); - await dispatch(actions.updateSendAmount('0x0')); - await dispatch(addHistoryEntry(`sendFlow - user toggled max mode off`)); - } else { - await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); - await dispatch(actions.updateAmountToMax()); - await dispatch(addHistoryEntry(`sendFlow - user toggled max mode on`)); + dispatch(actions.resetSendState()); + + if (state[name].gasEstimatePollToken) { + await disconnectGasFeeEstimatePoller(state[name].gasEstimatePollToken); + removePollingTokenFromAppState(state[name].gasEstimatePollToken); } - await dispatch(computeEstimatedGasLimit()); }; } @@ -1976,12 +2091,16 @@ export function toggleSendMaxMode() { * will create the transaction in state (by way of the various global provider * constructs) which will eventually (and fairly quickly from user perspective) * result in a confirmation window being displayed for the transaction. + * + * @returns {ThunkAction} */ export function signTransaction() { return async (dispatch, getState) => { const state = getState(); - const { id, asset, stage, eip1559support } = state[name]; + const { stage, eip1559support } = state[name]; const txParams = generateTransactionParams(state[name]); + const draftTransaction = + state[name].draftTransactions[state[name].currentTransactionUUID]; if (stage === SEND_STAGES.EDIT) { // When dealing with the edit flow there is already a transaction in // state that we must update, this branch is responsible for that logic. @@ -1989,7 +2108,7 @@ export function signTransaction() { // merge in the modified txParams. Once the transaction has been modified // we can send that to the background to update the transaction in state. const unapprovedTxs = getUnapprovedTxs(state); - const unapprovedTx = unapprovedTxs[id]; + const unapprovedTx = unapprovedTxs[draftTransaction.id]; // We only update the tx params that can be changed via the edit flow UX const eip1559OnlyTxParamsToUpdate = { data: txParams.data, @@ -2014,15 +2133,22 @@ export function signTransaction() { `sendFlow - user clicked next and transaction should be updated in controller`, ), ); - await dispatch(updateTransactionSendFlowHistory(id, state[name].history)); - dispatch(updateEditableParams(id, editingTx.txParams)); - dispatch(updateTransactionGasFees(id, editingTx.txParams)); + await dispatch( + updateTransactionSendFlowHistory( + draftTransaction.id, + draftTransaction.history, + ), + ); + dispatch(updateEditableParams(draftTransaction.id, editingTx.txParams)); + dispatch( + updateTransactionGasFees(draftTransaction.id, editingTx.txParams), + ); } else { let transactionType = TRANSACTION_TYPES.SIMPLE_SEND; - if (asset.type !== ASSET_TYPES.NATIVE) { + if (draftTransaction.asset.type !== ASSET_TYPES.NATIVE) { transactionType = - asset.type === ASSET_TYPES.COLLECTIBLE + draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ? TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM : TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER; } @@ -2036,155 +2162,155 @@ export function signTransaction() { addUnapprovedTransactionAndRouteToConfirmationPage( txParams, transactionType, - state[name].history, + draftTransaction.history, ), ); } }; } -export function editTransaction( - assetType, - transactionId, - tokenData, - assetDetails, -) { +/** + * Toggles the amount.mode between INPUT and MAX modes. + * As a result, the amount.value will change to either '0x0' when moving from + * MAX to INPUT, or to the maximum allowable amount based on current asset when + * moving from INPUT to MAX. + * + * @returns {ThunkAction} + */ +export function toggleSendMaxMode() { return async (dispatch, getState) => { const state = getState(); - await dispatch( - addHistoryEntry( - `sendFlow - user clicked edit on transaction with id ${transactionId}`, - ), - ); - const unapprovedTransactions = getUnapprovedTxs(state); - const transaction = unapprovedTransactions[transactionId]; - const { txParams } = transaction; - if (assetType === ASSET_TYPES.NATIVE) { - const { - data, - from, - gas: gasLimit, - gasPrice, - to: address, - value: amount, - } = txParams; - const nickname = getAddressBookEntry(state, address)?.name ?? ''; - await dispatch( - actions.editTransaction({ - data, - id: transactionId, - gasLimit, - gasPrice, - from, - amount, - address, - nickname, - }), - ); - } else if (!tokenData || !assetDetails) { - throw new Error( - `send/editTransaction dispatched with assetType 'TOKEN' but missing assetData or assetDetails parameter`, - ); - } else if (assetType === ASSET_TYPES.TOKEN) { - const { - data, - from, - to: tokenAddress, - gas: gasLimit, - gasPrice, - } = txParams; - const tokenAmountInDec = getTokenValueParam(tokenData); - const address = getTokenAddressParam(tokenData); - const nickname = getAddressBookEntry(state, address)?.name ?? ''; - - const tokenAmountInHex = addHexPrefix( - conversionUtil(tokenAmountInDec, { - fromNumericBase: 'dec', - toNumericBase: 'hex', - }), - ); - - await dispatch( - updateSendAsset({ - type: ASSET_TYPES.TOKEN, - details: { ...assetDetails, address: tokenAddress }, - }), - ); - - await dispatch( - actions.editTransaction({ - data, - id: transactionId, - gasLimit, - gasPrice, - from, - amount: tokenAmountInHex, - address, - 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, - }), - ); + if (state[name].amountMode === AMOUNT_MODES.MAX) { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.INPUT)); + await dispatch(actions.updateSendAmount('0x0')); + await dispatch(addHistoryEntry(`sendFlow - user toggled max mode off`)); + } else { + await dispatch(actions.updateAmountMode(AMOUNT_MODES.MAX)); + await dispatch(actions.updateAmountToMax()); + await dispatch(addHistoryEntry(`sendFlow - user toggled max mode on`)); } + await dispatch(computeEstimatedGasLimit()); + }; +} + +/** + * Begins a new draft transaction, clearing out the previous draft transactions + * from state, and clearing the currentTransactionUUID. This action is one of + * the two entry points into the send flow. NOTE: You must route to the send + * page *after* dispatching this action resolves to ensure that the + * draftTransaction is properly created. + * + * @param {Pick} asset - A partial asset + * object containing at least the asset type. If specifying a non-native asset + * then the asset details must be included with at least the address. + * @returns {ThunkAction} + */ +export function startNewDraftTransaction(asset) { + return async (dispatch) => { + await dispatch(actions.clearPreviousDrafts()); + + await dispatch( + actions.addNewDraft({ + ...draftTransactionInitialState, + history: [`sendFlow - User started new draft transaction`], + }), + ); + + await dispatch( + updateSendAsset({ + type: asset.type ?? ASSET_TYPES.NATIVE, + details: asset.details, + }), + ); + + await dispatch(initializeSendState()); }; } // Selectors +/** + * The following typedef is a shortcut for typing selectors below. It uses a + * generic type, T, so that each selector can specify it's return type. + * + * @template T + * @typedef {(state: MetaMaskState) => T} Selector + */ + +/** + * Selector that returns the current draft transaction's UUID. + * + * @type {Selector} + */ +export function getCurrentTransactionUUID(state) { + return state[name].currentTransactionUUID; +} + +/** + * Selector that returns the current draft transaction. + * + * @type {Selector} + */ +export function getCurrentDraftTransaction(state) { + return state[name].draftTransactions[getCurrentTransactionUUID(state)] ?? {}; +} // Gas selectors + +/** + * Selector that returns the current draft transaction's gasLimit. + * + * @type {Selector} + */ export function getGasLimit(state) { - return state[name].gas.gasLimit; + return getCurrentDraftTransaction(state).gas?.gasLimit; } +/** + * Selector that returns the current draft transaction's gasPrice. + * + * @type {Selector} + */ export function getGasPrice(state) { - return state[name].gas.gasPrice; + return getCurrentDraftTransaction(state).gas?.gasPrice; } +/** + * Selector that returns the current draft transaction's gasTotal. + * + * @type {Selector} + */ export function getGasTotal(state) { - return state[name].gas.gasTotal; + return getCurrentDraftTransaction(state).gas?.gasTotal; } +/** + * Selector that returns the error, if present, for the gas fields. + * + * @type {Selector} + */ export function gasFeeIsInError(state) { - return Boolean(state[name].gas.error); + return Boolean(getCurrentDraftTransaction(state).gas?.error); } +/** + * Selector that returns the minimum gasLimit for the current network. + * + * @type {Selector} + */ export function getMinimumGasLimitForSend(state) { - return state[name].gas.minimumGasLimit; + return state[name].gasLimitMinimum; } +/** + * Selector that returns the current draft transaction's gasLimit. + * + * @type {Selector>} + */ export function getGasInputMode(state) { const isMainnet = getIsMainnet(state); const gasEstimateType = getGasEstimateType(state); const showAdvancedGasFields = getAdvancedInlineGasShown(state); - if (state[name].gas.isCustomGasSet) { + if (state[name].gasIsSetInModal) { return GAS_INPUT_MODES.CUSTOM; } if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { @@ -2204,95 +2330,200 @@ export function getGasInputMode(state) { } // Asset Selectors +/** + * Selector that returns the asset the current draft transaction is sending. + * + * @type {Selector} + */ export function getSendAsset(state) { - return state[name].asset; + return getCurrentDraftTransaction(state).asset; } +/** + * Selector that returns the contract address of the non-native asset that + * the current transaction is sending, if it exists. + * + * @type {Selector} + */ export function getSendAssetAddress(state) { return getSendAsset(state)?.details?.address; } +/** + * Selector that returns a boolean value describing whether the currently + * selected asset is sendable, based upon the standard of the token. + * + * @type {Selector} + */ export function getIsAssetSendable(state) { - if (state[name].asset.type === ASSET_TYPES.NATIVE) { + if (getSendAsset(state)?.type === ASSET_TYPES.NATIVE) { return true; } - return state[name].asset.details.isERC721 === false; + return getSendAsset(state)?.details?.isERC721 === false; } +/** + * Selector that returns the asset error if it exists. + * + * @type {Selector} + */ export function getAssetError(state) { - return state[name].asset.error; + return getSendAsset(state).error; } // Amount Selectors +/** + * Selector that returns the amount that current draft transaction is sending. + * + * @type {Selector} + */ export function getSendAmount(state) { - return state[name].amount.value; + return getCurrentDraftTransaction(state).amount?.value; } +/** + * Selector that returns true if the user has enough native asset balance to + * cover the cost of the transaction. + * + * @type {Selector} + */ export function getIsBalanceInsufficient(state) { - return state[name].gas.error === INSUFFICIENT_FUNDS_ERROR; + return ( + getCurrentDraftTransaction(state).gas?.error === INSUFFICIENT_FUNDS_ERROR + ); } + +/** + * Selector that returns the amoung send mode, either MAX or INPUT. + * + * @type {Selector} + */ export function getSendMaxModeState(state) { - return state[name].amount.mode === AMOUNT_MODES.MAX; + return state[name].amountMode === AMOUNT_MODES.MAX; } +/** + * Selector that returns the current draft transaction's data field. + * + * @type {Selector} + */ export function getSendHexData(state) { - return state[name].userInputHexData; + return getCurrentDraftTransaction(state).userInputHexData; } +/** + * Selector that returns the current draft transaction's id, if present. + * + * @type {Selector} + */ export function getDraftTransactionID(state) { - return state[name].id; + return getCurrentDraftTransaction(state).id; } +/** + * Selector that returns true if there is an error on the amount field. + * + * @type {Selector} + */ export function sendAmountIsInError(state) { - return Boolean(state[name].amount.error); + return Boolean(getCurrentDraftTransaction(state).amount?.error); } // Recipient Selectors - -export function getSendTo(state) { - return state[name].recipient.address; -} - -export function getIsUsingMyAccountForRecipientSearch(state) { - return state[name].recipient.mode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; -} - -export function getRecipientUserInput(state) { - return state[name].recipient.userInput; -} - +/** + * Selector that returns the current draft transaction's recipient. + * + * @type {Selector} + */ export function getRecipient(state) { - const checksummedAddress = toChecksumHexAddress( - state[name].recipient.address, - ); + const draft = getCurrentDraftTransaction(state); + if (!draft.recipient) { + return { + address: '', + nickname: '', + error: null, + warning: null, + }; + } + const checksummedAddress = toChecksumHexAddress(draft.recipient.address); if (state.metamask.ensResolutionsByAddress) { return { - ...state[name].recipient, + ...draft.recipient, nickname: - state[name].recipient.nickname || + draft.recipient.nickname || getEnsResolutionByAddress(state, checksummedAddress), }; } - return state[name].recipient; + return draft.recipient; +} + +/** + * Selector that returns the addres of the current draft transaction's + * recipient. + * + * @type {Selector} + */ +export function getSendTo(state) { + return getRecipient(state)?.address; +} + +/** + * Selector that returns true if the current recipientMode is MY_ACCOUNTS + * + * @type {Selector} + */ +export function getIsUsingMyAccountForRecipientSearch(state) { + return state[name].recipientMode === RECIPIENT_SEARCH_MODES.MY_ACCOUNTS; +} + +/** + * Selector that returns the value that the user has typed into the recipient + * input field. + * + * @type {Selector} + */ +export function getRecipientUserInput(state) { + return state[name].recipientInput; } // Overall validity and stage selectors +/** + * Selector that returns the gasFee and amount errors, if they exist. + * + * @type {Selector<{ gasFee?: string, amount?: string}>} + */ export function getSendErrors(state) { return { - gasFee: state.send.gas.error, - amount: state.send.amount.error, + gasFee: getCurrentDraftTransaction(state).gas?.error, + amount: getCurrentDraftTransaction(state).amount?.error, }; } +/** + * Selector that returns true if the stage is anything except INACTIVE + * + * @type {Selector} + */ export function isSendStateInitialized(state) { return state[name].stage !== SEND_STAGES.INACTIVE; } +/** + * Selector that returns true if the current draft transaction is valid and in + * a sendable state. + * + * @type {Selector} + */ export function isSendFormInvalid(state) { - return state[name].status === SEND_STATUSES.INVALID; + return getCurrentDraftTransaction(state).status === SEND_STATUSES.INVALID; } +/** + * Selector that returns the current stage of the send flow + * + * @type {Selector>} + */ export function getSendStage(state) { return state[name].stage; } diff --git a/ui/ducks/send/send.test.js b/ui/ducks/send/send.test.js index 707460980..1b7ef3608 100644 --- a/ui/ducks/send/send.test.js +++ b/ui/ducks/send/send.test.js @@ -18,10 +18,19 @@ import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../../shared/constants/gas'; import { ASSET_TYPES, TRANSACTION_ENVELOPE_TYPES, - TRANSACTION_TYPES, } from '../../../shared/constants/transaction'; import * as Actions from '../../store/actions'; import { setBackgroundConnection } from '../../../test/jest'; +import { + generateERC20TransferData, + generateERC721TransferData, +} from '../../pages/send/send.utils'; +import { BURN_ADDRESS } from '../../../shared/modules/hexstring-utils'; +import { TOKEN_STANDARDS } from '../../helpers/constants/common'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../test/jest/mocks'; import sendReducer, { initialState, initializeSendState, @@ -39,7 +48,6 @@ import sendReducer, { SEND_STAGES, AMOUNT_MODES, RECIPIENT_SEARCH_MODES, - editTransaction, getGasLimit, getGasPrice, getGasTotal, @@ -66,6 +74,7 @@ import sendReducer, { getSendStage, updateGasPrice, } from './send'; +import { draftTransactionInitialState, editExistingTransaction } from '.'; const mockStore = createMockStore([thunk]); @@ -86,6 +95,8 @@ setBackgroundConnection({ updateTransactionSendFlowHistory: jest.fn((_x, _y, cb) => cb(null)), }); +const getTestUUIDTx = (state) => state.draftTransactions['test-uuid']; + describe('Send Slice', () => { let getTokenStandardAndDetailsStub; let addUnapprovedTransactionAndRouteToConfirmationPageStub; @@ -93,7 +104,14 @@ describe('Send Slice', () => { jest.useFakeTimers(); getTokenStandardAndDetailsStub = jest .spyOn(Actions, 'getTokenStandardAndDetails') - .mockImplementation(() => Promise.resolve({ standard: 'ERC20' })); + .mockImplementation(() => + Promise.resolve({ + standard: 'ERC20', + balance: '0x0', + symbol: 'SYMB', + decimals: 18, + }), + ); addUnapprovedTransactionAndRouteToConfirmationPageStub = jest.spyOn( Actions, 'addUnapprovedTransactionAndRouteToConfirmationPage', @@ -119,11 +137,130 @@ describe('Send Slice', () => { }); describe('Reducers', () => { + describe('addNewDraft', () => { + it('should add new draft transaction and set currentTransactionUUID', () => { + const action = { + type: 'send/addNewDraft', + payload: { ...draftTransactionInitialState, id: 4 }, + }; + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); + const uuid = result.currentTransactionUUID; + const draft = result.draftTransactions[uuid]; + expect(draft.id).toStrictEqual(4); + }); + }); + describe('addHistoryEntry', () => { + it('should append a history item to the current draft transaction, including timestamp', () => { + const action = { + type: 'send/addHistoryEntry', + payload: 'test entry', + }; + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + const latestHistory = draft.history[draft.history.length - 1]; + expect(latestHistory.timestamp).toBeDefined(); + expect(latestHistory.entry).toStrictEqual('test entry'); + }); + }); + describe('calculateGasTotal', () => { + it('should set gasTotal to maxFeePerGax * gasLimit for FEE_MARKET transaction', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x1', + maxFeePerGas: '0x2', + gasLimit: GAS_LIMITS.SIMPLE, + }, + transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, + }), + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.gas.gasTotal).toStrictEqual(`0xa410`); + }); + + it('should set gasTotal to gasPrice * gasLimit for non FEE_MARKET transaction', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x1', + maxFeePerGas: '0x2', + gasLimit: GAS_LIMITS.SIMPLE, + }, + }), + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.gas.gasTotal).toStrictEqual(GAS_LIMITS.SIMPLE); + }); + + it('should call updateAmountToMax if amount mode is max', () => { + const action = { + type: 'send/calculateGasTotal', + }; + const result = sendReducer( + { + ...getInitialSendStateWithExistingTxState({ + asset: { balance: '0xffff' }, + gas: { + gasPrice: '0x1', + gasLimit: GAS_LIMITS.SIMPLE, + }, + recipient: { + address: '0x00', + }, + }), + selectedAccount: { + balance: '0xffff', + address: '0x00', + }, + gasEstimateIsLoading: false, + amountMode: AMOUNT_MODES.MAX, + stage: SEND_STAGES.DRAFT, + }, + action, + ); + expect(result.currentTransactionUUID).toStrictEqual('test-uuid'); + const draft = getTestUUIDTx(result); + expect(draft.amount.value).toStrictEqual('0xadf7'); + expect(draft.status).toStrictEqual(SEND_STATUSES.VALID); + }); + }); + describe('resetSendState', () => { + it('should set the state back to a blank slate matching the initialState object', () => { + const action = { + type: 'send/resetSendState', + }; + + const result = sendReducer({}, action); + + expect(result).toStrictEqual(initialState); + }); + }); describe('updateSendAmount', () => { it('should', async () => { const action = { type: 'send/updateSendAmount', payload: '0x1' }; - const result = sendReducer(initialState, action); - expect(result.amount.value).toStrictEqual('0x1'); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + expect(getTestUUIDTx(result).amount.value).toStrictEqual('0x1'); }); }); @@ -137,17 +274,19 @@ describe('Send Slice', () => { balance: '0x56bc75e2d63100000', // 100000000000000000000 }, gas: { - gasLimit: '0x5208', // 21000 + gasLimit: GAS_LIMITS.SIMPLE, // 21000 gasTotal: '0x1319718a5000', // 21000000000000 - minimumGasLimit: '0x5208', + minimumGasLimit: GAS_LIMITS.SIMPLE, }, }; - const state = { ...initialState, ...maxAmountState }; + const state = getInitialSendStateWithExistingTxState(maxAmountState); const action = { type: 'send/updateAmountToMax' }; const result = sendReducer(state, action); - expect(result.amount.value).toStrictEqual('0x56bc74b13f185b000'); // 99999979000000000000 + expect(getTestUUIDTx(result).amount.value).toStrictEqual( + '0x56bc74b13f185b000', + ); // 99999979000000000000 }); }); @@ -161,17 +300,22 @@ describe('Send Slice', () => { maxPriorityFeePerGas: '0x1', }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.maxFeePerGas).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.maxFeePerGas).toStrictEqual( action.payload.maxFeePerGas, ); - expect(result.gas.maxPriorityFeePerGas).toStrictEqual( + expect(draftTransaction.gas.maxPriorityFeePerGas).toStrictEqual( action.payload.maxPriorityFeePerGas, ); - expect(result.transactionType).toBe( + expect(draftTransaction.transactionType).toBe( TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, ); }); @@ -184,10 +328,19 @@ describe('Send Slice', () => { gasPrice: '0x1', }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); - expect(result.transactionType).toBe(TRANSACTION_ENVELOPE_TYPES.LEGACY); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gasPrice, + ); + expect(draftTransaction.transactionType).toBe( + TRANSACTION_ENVELOPE_TYPES.LEGACY, + ); }); }); @@ -197,54 +350,59 @@ describe('Send Slice', () => { type: 'send/updateUserInputHexData', payload: 'TestData', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + const draftTransaction = getTestUUIDTx(result); - expect(result.userInputHexData).toStrictEqual(action.payload); + expect(draftTransaction.userInputHexData).toStrictEqual(action.payload); }); }); describe('updateGasLimit', () => { const action = { type: 'send/updateGasLimit', - payload: '0x5208', // 21000 + payload: GAS_LIMITS.SIMPLE, // 21000 }; it('should', () => { const result = sendReducer( { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.DRAFT, - gas: { ...initialState.gas, isGasEstimateLoading: false }, + gasEstimateIsLoading: false, }, action, ); - expect(result.gas.gasLimit).toStrictEqual(action.payload); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasLimit).toStrictEqual(action.payload); }); it('should recalculate gasTotal', () => { - const gasState = { - ...initialState, + const gasState = getInitialSendStateWithExistingTxState({ gas: { gasLimit: '0x0', gasPrice: '0x3b9aca00', // 1000000000 }, - }; + }); const result = sendReducer(gasState, action); - expect(result.gas.gasLimit).toStrictEqual(action.payload); - expect(result.gas.gasPrice).toStrictEqual(gasState.gas.gasPrice); - expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasLimit).toStrictEqual(action.payload); + expect(draftTransaction.gas.gasPrice).toStrictEqual('0x3b9aca00'); + expect(draftTransaction.gas.gasTotal).toStrictEqual('0x1319718a5000'); // 21000000000000 }); }); describe('updateAmountMode', () => { it('should change to INPUT amount mode', () => { const emptyAmountModeState = { - amount: { - mode: '', - }, + amountMode: '', }; const action = { @@ -253,7 +411,7 @@ describe('Send Slice', () => { }; const result = sendReducer(emptyAmountModeState, action); - expect(result.amount.mode).toStrictEqual(action.payload); + expect(result.amountMode).toStrictEqual(action.payload); }); it('should change to MAX amount mode', () => { @@ -261,9 +419,12 @@ describe('Send Slice', () => { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.mode).toStrictEqual(action.payload); + expect(result.amountMode).toStrictEqual(action.payload); }); it('should', () => { @@ -271,21 +432,23 @@ describe('Send Slice', () => { type: 'send/updateAmountMode', payload: 'RANDOM', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.mode).not.toStrictEqual(action.payload); + expect(result.amountMode).not.toStrictEqual(action.payload); }); }); describe('updateAsset', () => { it('should update asset type and balance from respective action payload', () => { - const updateAssetState = { - ...initialState, + const updateAssetState = getInitialSendStateWithExistingTxState({ asset: { type: 'old type', balance: 'old balance', }, - }; + }); const action = { type: 'send/updateAsset', @@ -297,20 +460,23 @@ describe('Send Slice', () => { const result = sendReducer(updateAssetState, action); - expect(result.asset.type).toStrictEqual(action.payload.type); - expect(result.asset.balance).toStrictEqual(action.payload.balance); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.asset.type).toStrictEqual(action.payload.type); + expect(draftTransaction.asset.balance).toStrictEqual( + action.payload.balance, + ); }); it('should nullify old contract address error when asset types is not TOKEN', () => { - const recipientErrorState = { - ...initialState, + const recipientErrorState = getInitialSendStateWithExistingTxState({ recipient: { error: CONTRACT_ADDRESS_ERROR, }, asset: { type: ASSET_TYPES.TOKEN, }, - }; + }); const action = { type: 'send/updateAsset', @@ -321,22 +487,23 @@ describe('Send Slice', () => { const result = sendReducer(recipientErrorState, action); - expect(result.recipient.error).not.toStrictEqual( - recipientErrorState.recipient.error, + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).not.toStrictEqual( + CONTRACT_ADDRESS_ERROR, ); - expect(result.recipient.error).toBeNull(); + expect(draftTransaction.recipient.error).toBeNull(); }); it('should nullify old known address error when asset types is not TOKEN', () => { - const recipientErrorState = { - ...initialState, + const recipientErrorState = getInitialSendStateWithExistingTxState({ recipient: { warning: KNOWN_RECIPIENT_ADDRESS_WARNING, }, asset: { type: ASSET_TYPES.TOKEN, }, - }; + }); const action = { type: 'send/updateAsset', @@ -347,10 +514,12 @@ describe('Send Slice', () => { const result = sendReducer(recipientErrorState, action); - expect(result.recipient.warning).not.toStrictEqual( - recipientErrorState.recipient.warning, + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.warning).not.toStrictEqual( + KNOWN_RECIPIENT_ADDRESS_WARNING, ); - expect(result.recipient.warning).toBeNull(); + expect(draftTransaction.recipient.warning).toBeNull(); }); it('should update asset type and details to TOKEN payload', () => { @@ -366,9 +535,17 @@ describe('Send Slice', () => { }, }; - const result = sendReducer(initialState, action); - expect(result.asset.type).toStrictEqual(action.payload.type); - expect(result.asset.details).toStrictEqual(action.payload.details); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.asset.type).toStrictEqual(action.payload.type); + expect(draftTransaction.asset.details).toStrictEqual( + action.payload.details, + ); }); }); @@ -381,10 +558,17 @@ describe('Send Slice', () => { }, }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); + + const draftTransaction = getTestUUIDTx(result); expect(result.stage).toStrictEqual(SEND_STAGES.DRAFT); - expect(result.recipient.address).toStrictEqual(action.payload.address); + expect(draftTransaction.recipient.address).toStrictEqual( + action.payload.address, + ); }); }); @@ -394,9 +578,12 @@ describe('Send Slice', () => { type: 'send/useDefaultGas', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.isCustomGasSet).toStrictEqual(false); + expect(result.gasIsSetInModal).toStrictEqual(false); }); }); @@ -406,9 +593,12 @@ describe('Send Slice', () => { type: 'send/useCustomGas', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.isCustomGasSet).toStrictEqual(true); + expect(result.gasIsSetInModal).toStrictEqual(true); }); }); @@ -419,21 +609,26 @@ describe('Send Slice', () => { payload: 'user input', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.recipient.userInput).toStrictEqual(action.payload); + expect(result.recipientInput).toStrictEqual(action.payload); }); }); describe('validateRecipientUserInput', () => { it('should set recipient error and warning to null when user input is', () => { const noUserInputState = { - recipient: { - mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, - userInput: '', - error: 'someError', - warning: 'someWarning', - }, + ...getInitialSendStateWithExistingTxState({ + recipient: { + error: 'someError', + warning: 'someWarning', + }, + }), + recipientInput: '', + recipientMode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, }; const action = { @@ -442,16 +637,16 @@ describe('Send Slice', () => { const result = sendReducer(noUserInputState, action); - expect(result.recipient.error).toBeNull(); - expect(result.recipient.warning).toBeNull(); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toBeNull(); + expect(draftTransaction.recipient.warning).toBeNull(); }); it('should error with an invalid address error when user input is not a valid hex string', () => { const tokenAssetTypeState = { - ...initialState, - recipient: { - userInput: '0xValidateError', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0xValidateError', }; const action = { type: 'send/validateRecipientUserInput', @@ -465,16 +660,18 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toStrictEqual( + 'invalidAddressRecipient', + ); }); // TODO: Expectation might change in the future it('should error with an invalid network error when user input is not a valid hex string on a non default network', () => { const tokenAssetTypeState = { - ...initialState, - recipient: { - userInput: '0xValidateError', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0xValidateError', }; const action = { type: 'send/validateRecipientUserInput', @@ -488,17 +685,17 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toStrictEqual( 'invalidAddressRecipientNotEthNetwork', ); }); it('should error with invalid address recipient when the user inputs the burn address', () => { const tokenAssetTypeState = { - ...initialState, - recipient: { - userInput: '0x0000000000000000000000000000000000000000', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: '0x0000000000000000000000000000000000000000', }; const action = { type: 'send/validateRecipientUserInput', @@ -512,21 +709,24 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetTypeState, action); - expect(result.recipient.error).toStrictEqual('invalidAddressRecipient'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.error).toStrictEqual( + 'invalidAddressRecipient', + ); }); it('should error with same address recipient as a token', () => { const tokenAssetTypeState = { - ...initialState, - asset: { - type: ASSET_TYPES.TOKEN, - details: { - address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', + }, }, - }, - recipient: { - userInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', - }, + }), + recipientInput: '0x0dcd5d886577d5081b0c52e242ef29e70be3e7bc', }; const action = { @@ -540,8 +740,11 @@ describe('Send Slice', () => { }; const result = sendReducer(tokenAssetTypeState, action); + const draftTransaction = getTestUUIDTx(result); - expect(result.recipient.error).toStrictEqual('contractAddressError'); + expect(draftTransaction.recipient.error).toStrictEqual( + 'contractAddressError', + ); }); }); @@ -552,28 +755,18 @@ describe('Send Slice', () => { payload: 'a-random-string', }; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.recipient.mode).toStrictEqual(action.payload); - }); - }); - - describe('resetSendState', () => { - it('should', () => { - const action = { - type: 'send/resetSendState', - }; - - const result = sendReducer({}, action); - - expect(result).toStrictEqual(initialState); + expect(result.recipientMode).toStrictEqual(action.payload); }); }); describe('validateAmountField', () => { it('should error with insufficient funds when amount asset value plust gas is higher than asset balance', () => { - const nativeAssetState = { - ...initialState, + const nativeAssetState = getInitialSendStateWithExistingTxState({ amount: { value: '0x6fc23ac0', // 1875000000 }, @@ -584,7 +777,7 @@ describe('Send Slice', () => { gas: { gasTotal: '0x8f0d180', // 150000000 }, - }; + }); const action = { type: 'send/validateAmountField', @@ -592,12 +785,15 @@ describe('Send Slice', () => { const result = sendReducer(nativeAssetState, action); - expect(result.amount.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toStrictEqual( + INSUFFICIENT_FUNDS_ERROR, + ); }); it('should error with insufficient tokens when amount value of tokens is higher than asset balance of token', () => { - const tokenAssetState = { - ...initialState, + const tokenAssetState = getInitialSendStateWithExistingTxState({ amount: { value: '0x77359400', // 2000000000 }, @@ -608,7 +804,7 @@ describe('Send Slice', () => { decimals: 0, }, }, - }; + }); const action = { type: 'send/validateAmountField', @@ -616,16 +812,19 @@ describe('Send Slice', () => { const result = sendReducer(tokenAssetState, action); - expect(result.amount.error).toStrictEqual(INSUFFICIENT_TOKENS_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toStrictEqual( + INSUFFICIENT_TOKENS_ERROR, + ); }); it('should error negative value amount', () => { - const negativeAmountState = { - ...initialState, + const negativeAmountState = getInitialSendStateWithExistingTxState({ amount: { value: '-1', }, - }; + }); const action = { type: 'send/validateAmountField', @@ -633,12 +832,13 @@ describe('Send Slice', () => { const result = sendReducer(negativeAmountState, action); - expect(result.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toStrictEqual(NEGATIVE_ETH_ERROR); }); it('should not error for positive value amount', () => { - const otherState = { - ...initialState, + const otherState = getInitialSendStateWithExistingTxState({ amount: { error: 'someError', value: '1', @@ -646,119 +846,135 @@ describe('Send Slice', () => { asset: { type: '', }, - }; + }); const action = { type: 'send/validateAmountField', }; const result = sendReducer(otherState, action); - expect(result.amount.error).toBeNull(); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.amount.error).toBeNull(); }); }); describe('validateGasField', () => { it('should error when total amount of gas is higher than account balance', () => { - const gasFieldState = { - ...initialState, + const gasFieldState = getInitialSendStateWithExistingTxState({ account: { balance: '0x0', }, gas: { gasTotal: '0x1319718a5000', // 21000000000000 }, - }; + }); const action = { type: 'send/validateGasField', }; const result = sendReducer(gasFieldState, action); - expect(result.gas.error).toStrictEqual(INSUFFICIENT_FUNDS_ERROR); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.error).toStrictEqual( + INSUFFICIENT_FUNDS_ERROR, + ); }); }); describe('validateSendState', () => { it('should set `INVALID` send state status when amount error is present', () => { - const amountErrorState = { - ...initialState, + const amountErrorState = getInitialSendStateWithExistingTxState({ amount: { error: 'Some Amount Error', }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(amountErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when gas error is present', () => { - const gasErrorState = { - ...initialState, + const gasErrorState = getInitialSendStateWithExistingTxState({ gas: { error: 'Some Amount Error', }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(gasErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when asset type is `TOKEN` without token details present', () => { - const assetErrorState = { - ...initialState, + const assetErrorState = getInitialSendStateWithExistingTxState({ asset: { type: ASSET_TYPES.TOKEN, }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(assetErrorState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `INVALID` send state status when gasLimit is under the minimumGasLimit', () => { - const gasLimitErroState = { - ...initialState, + const gasLimitErroState = getInitialSendStateWithExistingTxState({ gas: { gasLimit: '0x5207', - minimumGasLimit: '0x5208', + minimumGasLimit: GAS_LIMITS.SIMPLE, }, - }; + }); const action = { type: 'send/validateSendState', }; const result = sendReducer(gasLimitErroState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.INVALID); + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.INVALID); }); it('should set `VALID` send state status when conditionals have not been met', () => { const validSendStatusState = { - ...initialState, - stage: SEND_STAGES.DRAFT, - asset: { - type: ASSET_TYPES.TOKEN, - details: { - address: '0x000', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: { + address: '0x000', + }, }, - }, - gas: { - isGasEstimateLoading: false, - gasLimit: '0x5208', - minimumGasLimit: '0x5208', - }, + gas: { + gasLimit: GAS_LIMITS.SIMPLE, + }, + }), + stage: SEND_STAGES.DRAFT, + gasEstimateIsLoading: false, + minimumGasLimit: GAS_LIMITS.SIMPLE, }; const action = { @@ -767,19 +983,20 @@ describe('Send Slice', () => { const result = sendReducer(validSendStatusState, action); - expect(result.status).toStrictEqual(SEND_STATUSES.VALID); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.status).toStrictEqual(SEND_STATUSES.VALID); }); }); }); describe('extraReducers/externalReducers', () => { describe('QR Code Detected', () => { - const qrCodestate = { - ...initialState, + const qrCodestate = getInitialSendStateWithExistingTxState({ recipient: { address: '0xAddress', }, - }; + }); it('should set the recipient address to the scanned address value if they are not equal', () => { const action = { @@ -793,7 +1010,10 @@ describe('Send Slice', () => { }; const result = sendReducer(qrCodestate, action); - expect(result.recipient.address).toStrictEqual( + + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.address).toStrictEqual( action.value.values.address, ); }); @@ -811,10 +1031,10 @@ describe('Send Slice', () => { const result = sendReducer(qrCodestate, badQRAddressAction); - expect(result.recipient.address).toStrictEqual( - qrCodestate.recipient.address, - ); - expect(result.recipient.error).toStrictEqual( + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.recipient.address).toStrictEqual('0xAddress'); + expect(draftTransaction.recipient.error).toStrictEqual( INVALID_RECIPIENT_ADDRESS_ERROR, ); }); @@ -823,8 +1043,8 @@ describe('Send Slice', () => { describe('Selected Address Changed', () => { it('should update selected account address and balance on non-edit stages', () => { const olderState = { - ...initialState, - account: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + selectedAccount: { balance: '0x0', address: '0xAddress', }, @@ -842,10 +1062,10 @@ describe('Send Slice', () => { const result = sendReducer(olderState, action); - expect(result.account.balance).toStrictEqual( + expect(result.selectedAccount.balance).toStrictEqual( action.payload.account.balance, ); - expect(result.account.address).toStrictEqual( + expect(result.selectedAccount.address).toStrictEqual( action.payload.account.address, ); }); @@ -854,9 +1074,9 @@ describe('Send Slice', () => { describe('Account Changed', () => { it('should', () => { const accountsChangedState = { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, - account: { + selectedAccount: { address: '0xAddress', balance: '0x0', }, @@ -874,16 +1094,16 @@ describe('Send Slice', () => { const result = sendReducer(accountsChangedState, action); - expect(result.account.balance).toStrictEqual( + expect(result.selectedAccount.balance).toStrictEqual( action.payload.account.balance, ); }); it(`should not edit account balance if action payload address is not the same as state's address`, () => { const accountsChangedState = { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, - account: { + selectedAccount: { address: '0xAddress', balance: '0x0', }, @@ -900,10 +1120,10 @@ describe('Send Slice', () => { }; const result = sendReducer(accountsChangedState, action); - expect(result.account.address).not.toStrictEqual( + expect(result.selectedAccount.address).not.toStrictEqual( action.payload.account.address, ); - expect(result.account.balance).not.toStrictEqual( + expect(result.selectedAccount.balance).not.toStrictEqual( action.payload.account.balance, ); }); @@ -976,7 +1196,7 @@ describe('Send Slice', () => { }, }, }, - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING', basicEstimatesStatus: { @@ -1004,14 +1224,15 @@ describe('Send Slice', () => { describe('Set Basic Gas Estimate Data', () => { it('should recalculate gas based off of average basic estimate data', () => { const gasState = { - ...initialState, - gas: { - gasPrice: '0x0', - gasPriceEstimate: '0x0', - gasLimit: '0x5208', - gasTotal: '0x0', - minimumGasLimit: '0x5208', - }, + ...getInitialSendStateWithExistingTxState({ + gas: { + gasPrice: '0x0', + gasLimit: GAS_LIMITS.SIMPLE, + gasTotal: '0x0', + }, + }), + minimumGasLimit: GAS_LIMITS.SIMPLE, + gasPriceEstimate: '0x0', }; const action = { @@ -1026,9 +1247,11 @@ describe('Send Slice', () => { const result = sendReducer(gasState, action); - expect(result.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 - expect(result.gas.gasLimit).toStrictEqual(gasState.gas.gasLimit); - expect(result.gas.gasTotal).toStrictEqual('0x1319718a5000'); + const draftTransaction = getTestUUIDTx(result); + + expect(draftTransaction.gas.gasPrice).toStrictEqual('0x3b9aca00'); // 1000000000 + expect(draftTransaction.gas.gasLimit).toStrictEqual(GAS_LIMITS.SIMPLE); + expect(draftTransaction.gas.gasTotal).toStrictEqual('0x1319718a5000'); }); }); }); @@ -1037,11 +1260,11 @@ describe('Send Slice', () => { describe('updateGasPrice', () => { it('should update gas price and update draft transaction with validated state', async () => { const store = mockStore({ - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: undefined, }, - }, + }), }); const newGasPrice = '0x0'; @@ -1069,17 +1292,6 @@ describe('Send Slice', () => { }); describe('UpdateSendAmount', () => { - const defaultSendAmountState = { - send: { - amount: { - mode: undefined, - }, - asset: { - type: '', - }, - }, - }; - it('should create an action to update send amount', async () => { const sendState = { metamask: { @@ -1089,8 +1301,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { details: {}, }, @@ -1104,7 +1315,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(sendState); @@ -1143,8 +1354,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { details: {}, }, @@ -1158,7 +1368,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(sendState); @@ -1196,8 +1406,7 @@ describe('Send Slice', () => { chainId: '0x1', }, }, - ...defaultSendAmountState.send, - send: { + send: getInitialSendStateWithExistingTxState({ asset: { type: ASSET_TYPES.TOKEN, details: {}, @@ -1212,7 +1421,7 @@ describe('Send Slice', () => { value: '', }, userInputHexData: '', - }, + }), }; const store = mockStore(tokenAssetTypeSendState); @@ -1239,27 +1448,39 @@ describe('Send Slice', () => { blockGasLimit: '', selectedAddress: '', provider: { - chainId: '0x1', + chainId: RINKEBY_CHAIN_ID, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + accounts: { + '0xAddress': { + address: '0xAddress', + }, }, }, send: { - account: { - balance: '', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: '', + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + userInputHexData: '', + }), + selectedAccount: { + address: '0xAddress', }, - asset: { - type: '', - details: {}, - }, - gas: { - gasPrice: '', - }, - recipient: { - address: '', - }, - amount: { - value: '', - }, - userInputHexData: '', }, }; @@ -1267,48 +1488,44 @@ describe('Send Slice', () => { const store = mockStore(defaultSendAssetState); const newSendAsset = { - type: '', - details: { - address: '', - symbol: '', - decimals: '', - }, + type: ASSET_TYPES.NATIVE, }; await store.dispatch(updateSendAsset(newSendAsset)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(6); + expect(actionResult).toHaveLength(4); + expect(actionResult[0]).toMatchObject({ type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset type to ', + payload: 'sendFlow - user set asset of type NATIVE with symbol ETH', }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to ', - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to ', - }); - - expect(actionResult[3].type).toStrictEqual('send/updateAsset'); - expect(actionResult[3].payload).toStrictEqual({ - ...newSendAsset, - balance: '', + expect(actionResult[1].type).toStrictEqual('send/updateAsset'); + expect(actionResult[1].payload).toStrictEqual({ + type: ASSET_TYPES.NATIVE, + balance: '0x0', error: null, + details: null, }); - expect(actionResult[4].type).toStrictEqual( + expect(actionResult[2].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[5].type).toStrictEqual( + expect(actionResult[3].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); }); it('should create actions for updateSendAsset with tokens', async () => { + getTokenStandardAndDetailsStub.mockImplementation(() => + Promise.resolve({ + standard: 'ERC20', + balance: '0x0', + symbol: 'TokenSymbol', + decimals: 18, + }), + ); global.eth = { contract: sinon.stub().returns({ at: sinon.stub().returns({ @@ -1331,31 +1548,30 @@ describe('Send Slice', () => { const actionResult = store.getActions(); - expect(actionResult).toHaveLength(8); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to tokenSymbol', - }); + expect(actionResult).toHaveLength(6); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); expect(actionResult[2]).toMatchObject({ type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to tokenAddress', + payload: `sendFlow - user set asset to ERC20 token with symbol TokenSymbol and address tokenAddress`, }); - expect(actionResult[3].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[4].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[5].payload).toStrictEqual({ - ...newSendAsset, + expect(actionResult[3].payload).toStrictEqual({ + type: ASSET_TYPES.TOKEN, + details: { + address: 'tokenAddress', + symbol: 'TokenSymbol', + decimals: 18, + standard: 'ERC20', + balance: '0x0', + }, balance: '0x0', error: null, }); - expect(actionResult[6].type).toStrictEqual( + expect(actionResult[4].type).toStrictEqual( 'send/computeEstimatedGasLimit/pending', ); - expect(actionResult[7].type).toStrictEqual( + expect(actionResult[5].type).toStrictEqual( 'send/computeEstimatedGasLimit/rejected', ); }); @@ -1363,7 +1579,7 @@ describe('Send Slice', () => { it('should show ConvertTokenToNFT modal and throw "invalidAssetType" error when token passed in props is an ERC721 or ERC1155', async () => { process.env.COLLECTIBLES_V1 = true; getTokenStandardAndDetailsStub.mockImplementation(() => - Promise.resolve({ standard: 'ERC1155' }), + Promise.resolve({ standard: 'ERC1155', balance: '0x1' }), ); const store = mockStore(defaultSendAssetState); @@ -1380,22 +1596,10 @@ describe('Send Slice', () => { store.dispatch(updateSendAsset(newSendAsset)), ).rejects.toThrow('invalidAssetType'); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(6); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to tokenSymbol', - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to tokenAddress', - }); - expect(actionResult[3].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[4].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[5]).toStrictEqual({ + expect(actionResult).toHaveLength(3); + expect(actionResult[0].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[1].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[2]).toStrictEqual({ payload: { name: 'CONVERT_TOKEN_TO_NFT', tokenAddress: 'tokenAddress', @@ -1777,11 +1981,11 @@ describe('Send Slice', () => { describe('UpdateSendHexData', () => { const sendHexDataState = { - send: { + send: getInitialSendStateWithExistingTxState({ asset: { type: '', }, - }, + }), }; it('should create action to update hexData', async () => { @@ -1853,24 +2057,26 @@ describe('Send Slice', () => { ); }); - it('should create actions to toggle off max mode when send amount mode is max', async () => { + it('should create actions to toggle off max mode when send amount mode is max', async () => { const sendMaxModeState = { send: { - asset: { - type: ASSET_TYPES.TOKEN, - details: {}, - }, - gas: { - gasPrice: '', - }, - recipient: { - address: '', - }, - amount: { - mode: AMOUNT_MODES.MAX, - value: '', - }, - userInputHexData: '', + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + details: {}, + }, + gas: { + gasPrice: '', + }, + recipient: { + address: '', + }, + amount: { + value: '', + }, + userInputHexData: '', + }), + amountMode: AMOUNT_MODES.MAX, }, metamask: { provider: { @@ -1902,16 +2108,15 @@ describe('Send Slice', () => { describe('SignTransaction', () => { const signTransactionState = { - send: { + send: getInitialSendStateWithExistingTxState({ + id: 1, asset: {}, - stage: '', recipient: {}, amount: {}, - account: {}, gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }; it('should show confirm tx page when no other conditions for signing have been met', async () => { @@ -1944,23 +2149,24 @@ describe('Send Slice', () => { }, }, send: { - ...signTransactionState.send, - stage: SEND_STAGES.DRAFT, - id: 1, - account: { - address: '0x6784e8507A1A46443f7bDc8f8cA39bdA92A675A6', - }, - asset: { - details: { - address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + ...getInitialSendStateWithExistingTxState({ + id: 1, + asset: { + details: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + }, + type: 'TOKEN', }, - type: 'TOKEN', - }, - recipient: { - address: '4F90e18605Fd46F9F9Fab0e225D88e1ACf5F5324', - }, - amount: { - value: '0x1', + recipient: { + address: '4F90e18605Fd46F9F9Fab0e225D88e1ACf5F5324', + }, + amount: { + value: '0x1', + }, + }), + stage: SEND_STAGES.DRAFT, + selectedAccount: { + address: '0x6784e8507A1A46443f7bDc8f8cA39bdA92A675A6', }, }, }; @@ -1999,7 +2205,6 @@ describe('Send Slice', () => { send: { ...signTransactionState.send, stage: SEND_STAGES.EDIT, - id: 1, }, }; @@ -2026,10 +2231,12 @@ describe('Send Slice', () => { }); }); - describe('editTransaction', () => { + describe('editExistingTransaction', () => { it('should set up the appropriate state for editing a native asset transaction', async () => { const editTransactionState = { metamask: { + gasEstimateType: GAS_ESTIMATE_TYPES.NONE, + gasFeeEstimates: {}, provider: { chainId: RINKEBY_CHAIN_ID, }, @@ -2038,6 +2245,18 @@ describe('Send Slice', () => { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + tokenList: {}, unapprovedTxs: { 1: { id: 1, @@ -2053,49 +2272,99 @@ describe('Send Slice', () => { }, }, send: { - asset: { - type: '', - }, - recipient: { - address: 'Address', - nickname: 'NickName', - }, + // We are going to remove this transaction as a part of the flow, + // but we need this stub to have the fromAccount because for our + // action checker the state isn't actually modified after each + // action is ran. + ...getInitialSendStateWithExistingTxState({ + id: 1, + fromAccount: { + address: '0xAddress', + }, + }), }, }; const store = mockStore(editTransactionState); - await store.dispatch(editTransaction(ASSET_TYPES.NATIVE, 1)); + await store.dispatch(editExistingTransaction(ASSET_TYPES.NATIVE, 1)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(2); + expect(actionResult).toHaveLength(7); expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', + type: 'send/clearPreviousDrafts', }); - expect(actionResult[1].type).toStrictEqual('send/editTransaction'); - expect(actionResult[1].payload).toStrictEqual({ - address: '0xRecipientAddress', - amount: '0xde0b6b3a7640000', - data: '', - from: '0xAddress', - gasLimit: GAS_LIMITS.SIMPLE, - gasPrice: '0x3b9aca00', - id: 1, - nickname: '', + expect(actionResult[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + value: '0xde0b6b3a7640000', + error: null, + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: GAS_LIMITS.SIMPLE, + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: '0xRecipientAddress', + error: null, + nickname: '', + warning: null, + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: '', + }, }); const action = actionResult[1]; - const result = sendReducer(initialState, action); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); - expect(result.amount.value).toStrictEqual(action.payload.amount); + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); it('should set up the appropriate state for editing a collectible asset transaction', async () => { + getTokenStandardAndDetailsStub.mockImplementation(() => + Promise.resolve({ + standard: 'ERC721', + balance: '0x1', + address: '0xCollectibleAddress', + }), + ); const editTransactionState = { metamask: { blockGasLimit: '0x3a98', @@ -2108,13 +2377,29 @@ describe('Send Slice', () => { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, + tokenList: {}, unapprovedTxs: { 1: { id: 1, txParams: { - data: '', + data: generateERC721TransferData({ + toAddress: BURN_ADDRESS, + fromAddress: '0xAddress', + tokenId: ethers.BigNumber.from(15000).toString(), + }), from: '0xAddress', - to: '0xTokenAddress', + to: '0xCollectibleAddress', gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE, gasPrice: '0x3b9aca00', // 1000000000 value: '0x0', @@ -2123,25 +2408,12 @@ describe('Send Slice', () => { }, }, send: { - account: { - address: '0xAddress', - balance: '0x0', - }, - asset: { - type: '', - }, - gas: { - gasPrice: '', - }, - amount: { - value: '', - }, - userInputHexData: '', - - recipient: { - address: 'Address', - nickname: 'NickName', - }, + ...getInitialSendStateWithExistingTxState({ + id: 1, + test: 'wow', + gas: { gasLimit: GAS_LIMITS.SIMPLE }, + }), + stage: SEND_STAGES.EDIT, }, }; @@ -2157,78 +2429,106 @@ describe('Send Slice', () => { const store = mockStore(editTransactionState); await store.dispatch( - editTransaction( - ASSET_TYPES.COLLECTIBLE, - 1, - { - name: TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM, - args: { - _to: '0xRecipientAddress', - _value: ethers.BigNumber.from(15000), - }, - }, - { - 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', - }, - ), + editExistingTransaction(ASSET_TYPES.COLLECTIBLE, 1), ); const actionResult = store.getActions(); expect(actionResult).toHaveLength(9); expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', + type: 'send/clearPreviousDrafts', }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.COLLECTIBLE}`, - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to undefined', - }); - expect(actionResult[3]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to 0xTokenAddress', - }); - expect(actionResult[4].type).toStrictEqual('send/updateAsset'); - expect(actionResult[4].payload).toStrictEqual({ - balance: '0x1', - type: ASSET_TYPES.COLLECTIBLE, - error: null, - details: { - address: '0xTokenAddress', - 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[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + error: null, + value: '0x1', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: GAS_LIMITS.BASE_TOKEN_ESTIMATE, + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: BURN_ADDRESS, + error: null, + nickname: '', + warning: null, + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: + editTransactionState.metamask.unapprovedTxs[1].txParams.data, + }, + }); + expect(actionResult[2].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[3].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[4]).toStrictEqual({ + type: 'send/addHistoryEntry', + payload: + 'sendFlow - user set asset to NFT with tokenId 15000 and address 0xCollectibleAddress', + }); + expect(actionResult[5]).toStrictEqual({ + type: 'send/updateAsset', + payload: { + balance: '0x1', + details: { + address: '0xCollectibleAddress', + balance: '0x1', + standard: TOKEN_STANDARDS.ERC721, + tokenId: '15000', + }, + error: null, + type: ASSET_TYPES.COLLECTIBLE, }, }); - expect(actionResult[5].type).toStrictEqual( - 'send/computeEstimatedGasLimit/pending', - ); expect(actionResult[6].type).toStrictEqual( - 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + 'send/initializeSendState/pending', ); - expect(actionResult[7].type).toStrictEqual( - 'send/computeEstimatedGasLimit/fulfilled', + expect(actionResult[7]).toStrictEqual({ + type: 'metamask/gas/SET_CUSTOM_GAS_LIMIT', + value: GAS_LIMITS.SIMPLE, + }); + expect(actionResult[8].type).toStrictEqual( + 'send/initializeSendState/fulfilled', ); - expect(actionResult[8].type).toStrictEqual('send/editTransaction'); - const action = actionResult[8]; - const result = sendReducer(initialState, action); + const action = actionResult[1]; - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + const result = sendReducer( + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + action, + ); - expect(result.amount.value).toStrictEqual(action.payload.amount); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); + + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); }); @@ -2240,16 +2540,46 @@ describe('Send Slice', () => { provider: { chainId: RINKEBY_CHAIN_ID, }, - tokens: [], + tokens: [ + { + address: '0xTokenAddress', + symbol: 'SYMB', + }, + ], + tokenList: { + '0xTokenAddress': { + symbol: 'SYMB', + address: '0xTokenAddress', + }, + }, addressBook: { [RINKEBY_CHAIN_ID]: {}, }, identities: {}, + accounts: { + '0xAddress': { + address: '0xAddress', + balance: '0x0', + }, + }, + cachedBalances: { + [RINKEBY_CHAIN_ID]: { + '0xAddress': '0x0', + }, + }, unapprovedTxs: { 1: { id: 1, txParams: { - data: '', + data: generateERC20TransferData({ + toAddress: BURN_ADDRESS, + amount: '0x3a98', + sendToken: { + address: '0xTokenAddress', + symbol: 'SYMB', + decimals: 18, + }, + }), from: '0xAddress', to: '0xTokenAddress', gas: GAS_LIMITS.BASE_TOKEN_ESTIMATE, @@ -2260,24 +2590,18 @@ describe('Send Slice', () => { }, }, send: { - account: { + ...getInitialSendStateWithExistingTxState({ + id: 1, + recipient: { + address: 'Address', + nickname: 'NickName', + }, + }), + selectedAccount: { address: '0xAddress', balance: '0x0', }, - asset: { - type: '', - }, - gas: { - gasPrice: '', - }, - amount: { - value: '', - }, - userInputHexData: '', - recipient: { - address: 'Address', - nickname: 'NickName', - }, + stage: SEND_STAGES.EDIT, }, }; @@ -2292,118 +2616,145 @@ describe('Send Slice', () => { 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 }, - ), - ); + await store.dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, 1)); const actionResult = store.getActions(); - expect(actionResult).toHaveLength(11); - expect(actionResult[0]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user clicked edit on transaction with id 1', - }); - expect(actionResult[1]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: `sendFlow - user set asset type to ${ASSET_TYPES.TOKEN}`, - }); - expect(actionResult[2]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset symbol to SYMB', - }); - expect(actionResult[3]).toMatchObject({ - type: 'send/addHistoryEntry', - payload: 'sendFlow - user set asset address to 0xTokenAddress', - }); - expect(actionResult[4].type).toStrictEqual('SHOW_LOADING_INDICATION'); - expect(actionResult[5].type).toStrictEqual('HIDE_LOADING_INDICATION'); - expect(actionResult[6].type).toStrictEqual('send/updateAsset'); - expect(actionResult[6].payload).toStrictEqual({ - balance: '0x0', - type: ASSET_TYPES.TOKEN, - error: null, - details: { - address: '0xTokenAddress', - decimals: 18, - symbol: 'SYMB', - standard: 'ERC20', + expect(actionResult).toHaveLength(9); + expect(actionResult[0].type).toStrictEqual('send/clearPreviousDrafts'); + expect(actionResult[1]).toStrictEqual({ + type: 'send/addNewDraft', + payload: { + amount: { + error: null, + value: '0x3a98', + }, + asset: { + balance: '0x0', + details: null, + error: null, + type: ASSET_TYPES.NATIVE, + }, + fromAccount: { + address: '0xAddress', + balance: '0x0', + }, + gas: { + error: null, + gasLimit: '0x186a0', + gasPrice: '0x3b9aca00', + gasTotal: '0x0', + maxFeePerGas: '0x0', + maxPriorityFeePerGas: '0x0', + }, + history: ['sendFlow - user clicked edit on transaction with id 1'], + id: 1, + recipient: { + address: BURN_ADDRESS, + error: null, + warning: null, + nickname: '', + }, + status: SEND_STATUSES.VALID, + transactionType: '0x0', + userInputHexData: + editTransactionState.metamask.unapprovedTxs[1].txParams.data, }, }); - expect(actionResult[7].type).toStrictEqual( - 'send/computeEstimatedGasLimit/pending', + expect(actionResult[2].type).toStrictEqual('SHOW_LOADING_INDICATION'); + expect(actionResult[3].type).toStrictEqual('HIDE_LOADING_INDICATION'); + expect(actionResult[4]).toMatchObject({ + type: 'send/addHistoryEntry', + payload: + 'sendFlow - user set asset to ERC20 token with symbol SYMB and address 0xTokenAddress', + }); + expect(actionResult[5]).toStrictEqual({ + type: 'send/updateAsset', + payload: { + balance: '0x0', + type: ASSET_TYPES.TOKEN, + error: null, + details: { + balance: '0x0', + address: '0xTokenAddress', + decimals: 18, + symbol: 'SYMB', + standard: 'ERC20', + }, + }, + }); + expect(actionResult[6].type).toStrictEqual( + 'send/initializeSendState/pending', ); - expect(actionResult[8].type).toStrictEqual( + expect(actionResult[7].type).toStrictEqual( 'metamask/gas/SET_CUSTOM_GAS_LIMIT', ); - expect(actionResult[9].type).toStrictEqual( - 'send/computeEstimatedGasLimit/fulfilled', + expect(actionResult[8].type).toStrictEqual( + 'send/initializeSendState/fulfilled', ); - expect(actionResult[10].type).toStrictEqual('send/editTransaction'); - expect(actionResult[10].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[10]; + const action = actionResult[1]; - const result = sendReducer(initialState, action); + const result = sendReducer(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, action); - expect(result.gas.gasLimit).toStrictEqual(action.payload.gasLimit); - expect(result.gas.gasPrice).toStrictEqual(action.payload.gasPrice); + expect(result.currentTransactionUUID).not.toStrictEqual('test-uuid'); - expect(result.amount.value).toStrictEqual(action.payload.amount); + const draftTransaction = + result.draftTransactions[result.currentTransactionUUID]; + + expect(draftTransaction.gas.gasLimit).toStrictEqual( + action.payload.gas.gasLimit, + ); + expect(draftTransaction.gas.gasPrice).toStrictEqual( + action.payload.gas.gasPrice, + ); + + expect(draftTransaction.amount.value).toStrictEqual( + action.payload.amount.value, + ); }); }); describe('selectors', () => { describe('gas selectors', () => { it('has a selector that gets gasLimit', () => { - expect(getGasLimit({ send: initialState })).toBe('0x0'); + expect( + getGasLimit({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector that gets gasPrice', () => { - expect(getGasPrice({ send: initialState })).toBe('0x0'); + expect( + getGasPrice({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector that gets gasTotal', () => { - expect(getGasTotal({ send: initialState })).toBe('0x0'); + expect( + getGasTotal({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector to determine if gas fee is in error', () => { - expect(gasFeeIsInError({ send: initialState })).toBe(false); + expect( + gasFeeIsInError({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( gasFeeIsInError({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ gas: { - ...initialState.gas, error: 'yes', }, - }, + }), }), ).toBe(true); }); it('has a selector that gets minimumGasLimit', () => { - expect(getMinimumGasLimitForSend({ send: initialState })).toBe( - GAS_LIMITS.SIMPLE, - ); + expect( + getMinimumGasLimitForSend({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(GAS_LIMITS.SIMPLE); }); describe('getGasInputMode selector', () => { @@ -2473,7 +2824,7 @@ describe('Send Slice', () => { process.env.IN_TEST = false; }); - it('returns CUSTOM if isCustomGasSet is true', () => { + it('returns CUSTOM if gasIsSetInModal is true', () => { expect( getGasInputMode({ metamask: { @@ -2481,11 +2832,8 @@ describe('Send Slice', () => { featureFlags: { advancedInlineGas: true }, }, send: { - ...initialState, - gas: { - ...initialState.send, - isCustomGasSet: true, - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + gasIsSetInModal: true, }, }), ).toBe(GAS_INPUT_MODES.CUSTOM); @@ -2495,38 +2843,39 @@ describe('Send Slice', () => { describe('asset selectors', () => { it('has a selector to get the asset', () => { - expect(getSendAsset({ send: initialState })).toMatchObject( - initialState.asset, + expect( + getSendAsset({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toMatchObject( + getTestUUIDTx(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT).asset, ); }); it('has a selector to get the asset address', () => { expect( getSendAssetAddress({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ asset: { balance: '0x0', details: { address: '0x0' }, type: ASSET_TYPES.TOKEN, }, - }, + }), }), ).toBe('0x0'); }); it('has a selector that determines if asset is sendable based on ERC721 status', () => { - expect(getIsAssetSendable({ send: initialState })).toBe(true); + expect( + getIsAssetSendable({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(true); expect( getIsAssetSendable({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ asset: { - ...initialState, type: ASSET_TYPES.TOKEN, details: { isERC721: true }, }, - }, + }), }), ).toBe(false); }); @@ -2534,65 +2883,77 @@ describe('Send Slice', () => { describe('amount selectors', () => { it('has a selector to get send amount', () => { - expect(getSendAmount({ send: initialState })).toBe('0x0'); + expect( + getSendAmount({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe('0x0'); }); it('has a selector to get if there is an insufficient funds error', () => { - expect(getIsBalanceInsufficient({ send: initialState })).toBe(false); expect( getIsBalanceInsufficient({ - send: { - ...initialState, - gas: { ...initialState.gas, error: INSUFFICIENT_FUNDS_ERROR }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(false); + expect( + getIsBalanceInsufficient({ + send: getInitialSendStateWithExistingTxState({ + gas: { error: INSUFFICIENT_FUNDS_ERROR }, + }), }), ).toBe(true); }); it('has a selector to get max mode state', () => { - expect(getSendMaxModeState({ send: initialState })).toBe(false); + expect( + getSendMaxModeState({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( getSendMaxModeState({ send: { - ...initialState, - amount: { ...initialState.amount, mode: AMOUNT_MODES.MAX }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + amountMode: AMOUNT_MODES.MAX, }, }), ).toBe(true); }); it('has a selector to get the draft transaction ID', () => { - expect(getDraftTransactionID({ send: initialState })).toBeNull(); expect( getDraftTransactionID({ - send: { - ...initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBeNull(); + expect( + getDraftTransactionID({ + send: getInitialSendStateWithExistingTxState({ id: 'ID', - }, + }), }), ).toBe('ID'); }); it('has a selector to get the user entered hex data', () => { - expect(getSendHexData({ send: initialState })).toBeNull(); + expect( + getSendHexData({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBeNull(); expect( getSendHexData({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ userInputHexData: '0x0', - }, + }), }), ).toBe('0x0'); }); it('has a selector to get if there is an amount error', () => { - expect(sendAmountIsInError({ send: initialState })).toBe(false); + expect( + sendAmountIsInError({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( sendAmountIsInError({ - send: { - ...initialState, - amount: { ...initialState.amount, error: 'any' }, - }, + send: getInitialSendStateWithExistingTxState({ + amount: { error: 'any' }, + }), }), ).toBe(true); }); @@ -2600,44 +2961,49 @@ describe('Send Slice', () => { describe('recipient selectors', () => { it('has a selector to get recipient address', () => { - expect(getSendTo({ send: initialState })).toBe(''); expect( getSendTo({ - send: { - ...initialState, - recipient: { ...initialState.recipient, address: '0xb' }, - }, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + metamask: { ensResolutionsByAddress: {} }, + }), + ).toBe(''); + expect( + getSendTo({ + send: getInitialSendStateWithExistingTxState({ + recipient: { address: '0xb' }, + }), + metamask: { ensResolutionsByAddress: {} }, }), ).toBe('0xb'); }); it('has a selector to check if using the my accounts option for recipient selection', () => { expect( - getIsUsingMyAccountForRecipientSearch({ send: initialState }), + getIsUsingMyAccountForRecipientSearch({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), ).toBe(false); expect( getIsUsingMyAccountForRecipientSearch({ send: { - ...initialState, - recipient: { - ...initialState.recipient, - mode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientMode: RECIPIENT_SEARCH_MODES.MY_ACCOUNTS, }, }), ).toBe(true); }); it('has a selector to get recipient user input in input field', () => { - expect(getRecipientUserInput({ send: initialState })).toBe(''); + expect( + getRecipientUserInput({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(''); expect( getRecipientUserInput({ send: { - ...initialState, - recipient: { - ...initialState.recipient, - userInput: 'domain.eth', - }, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + recipientInput: 'domain.eth', }, }), ).toBe('domain.eth'); @@ -2646,42 +3012,47 @@ describe('Send Slice', () => { it('has a selector to get recipient state', () => { expect( getRecipient({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, metamask: { ensResolutionsByAddress: {} }, }), - ).toMatchObject(initialState.recipient); + ).toMatchObject( + getTestUUIDTx(INITIAL_SEND_STATE_FOR_EXISTING_DRAFT).recipient, + ); }); }); describe('send validity selectors', () => { it('has a selector to get send errors', () => { - expect(getSendErrors({ send: initialState })).toMatchObject({ + expect( + getSendErrors({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toMatchObject({ gasFee: null, amount: null, }); expect( getSendErrors({ - send: { - ...initialState, + send: getInitialSendStateWithExistingTxState({ gas: { - ...initialState.gas, error: 'gasFeeTest', }, amount: { - ...initialState.amount, error: 'amountTest', }, - }, + }), }), ).toMatchObject({ gasFee: 'gasFeeTest', amount: 'amountTest' }); }); it('has a selector to get send state initialization status', () => { - expect(isSendStateInitialized({ send: initialState })).toBe(false); + expect( + isSendStateInitialized({ + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + }), + ).toBe(false); expect( isSendStateInitialized({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STATUSES.ADD_RECIPIENT, }, }), @@ -2689,19 +3060,28 @@ describe('Send Slice', () => { }); it('has a selector to get send state validity', () => { - expect(isSendFormInvalid({ send: initialState })).toBe(false); + expect( + isSendFormInvalid({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(false); expect( isSendFormInvalid({ - send: { ...initialState, status: SEND_STATUSES.INVALID }, + send: getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.INVALID, + }), }), ).toBe(true); }); it('has a selector to get send stage', () => { - expect(getSendStage({ send: initialState })).toBe(SEND_STAGES.INACTIVE); + expect( + getSendStage({ send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT }), + ).toBe(SEND_STAGES.INACTIVE); expect( getSendStage({ - send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.ADD_RECIPIENT, + }, }), ).toBe(SEND_STAGES.ADD_RECIPIENT); }); diff --git a/ui/pages/confirm-send-ether/confirm-send-ether.container.js b/ui/pages/confirm-send-ether/confirm-send-ether.container.js index eb794b6ce..80318ea46 100644 --- a/ui/pages/confirm-send-ether/confirm-send-ether.container.js +++ b/ui/pages/confirm-send-ether/confirm-send-ether.container.js @@ -1,7 +1,7 @@ import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; -import { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { ASSET_TYPES } from '../../../shared/constants/transaction'; import ConfirmSendEther from './confirm-send-ether.component'; @@ -20,7 +20,9 @@ const mapDispatchToProps = (dispatch) => { return { editTransaction: async (txData) => { const { id } = txData; - await dispatch(editTransaction(ASSET_TYPES.NATIVE, id.toString())); + await dispatch( + editExistingTransaction(ASSET_TYPES.NATIVE, id.toString()), + ); dispatch(clearConfirmTransaction()); }, }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.component.js b/ui/pages/confirm-send-token/confirm-send-token.component.js index 6e040fb06..774fbf670 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.component.js +++ b/ui/pages/confirm-send-token/confirm-send-token.component.js @@ -6,14 +6,15 @@ import { SEND_ROUTE } from '../../helpers/constants/routes'; export default class ConfirmSendToken extends Component { static propTypes = { history: PropTypes.object, - editTransaction: PropTypes.func, + editExistingTransaction: PropTypes.func, tokenAmount: PropTypes.string, }; handleEdit(confirmTransactionData) { - const { editTransaction, history } = this.props; - editTransaction(confirmTransactionData); - history.push(SEND_ROUTE); + const { editExistingTransaction, history } = this.props; + editExistingTransaction(confirmTransactionData).then(() => { + history.push(SEND_ROUTE); + }); } render() { diff --git a/ui/pages/confirm-send-token/confirm-send-token.container.js b/ui/pages/confirm-send-token/confirm-send-token.container.js index d8a498424..62cee9ac3 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.container.js +++ b/ui/pages/confirm-send-token/confirm-send-token.container.js @@ -3,7 +3,7 @@ import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import { clearConfirmTransaction } from '../../ducks/confirm-transaction/confirm-transaction.duck'; import { showSendTokenPage } from '../../store/actions'; -import { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { sendTokenTokenAmountAndToAddressSelector } from '../../selectors'; import { ASSET_TYPES } from '../../../shared/constants/transaction'; import ConfirmSendToken from './confirm-send-token.component'; @@ -18,18 +18,11 @@ const mapStateToProps = (state) => { const mapDispatchToProps = (dispatch) => { return { - editTransaction: ({ txData, tokenData, tokenProps: assetDetails }) => { + editExistingTransaction: async ({ txData }) => { const { id } = txData; - dispatch( - editTransaction( - ASSET_TYPES.TOKEN, - id.toString(), - tokenData, - assetDetails, - ), - ); - dispatch(clearConfirmTransaction()); - dispatch(showSendTokenPage()); + await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString())); + await dispatch(clearConfirmTransaction()); + await dispatch(showSendTokenPage()); }, }; }; diff --git a/ui/pages/confirm-send-token/confirm-send-token.js b/ui/pages/confirm-send-token/confirm-send-token.js index 8d40d36c9..a7afb7af4 100644 --- a/ui/pages/confirm-send-token/confirm-send-token.js +++ b/ui/pages/confirm-send-token/confirm-send-token.js @@ -4,7 +4,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; import ConfirmTokenTransactionBase from '../confirm-token-transaction-base/confirm-token-transaction-base'; import { SEND_ROUTE } from '../../helpers/constants/routes'; -import { editTransaction } from '../../ducks/send'; +import { editExistingTransaction } from '../../ducks/send'; import { contractExchangeRateSelector, getCurrentCurrency, @@ -35,27 +35,17 @@ export default function ConfirmSendToken({ const dispatch = useDispatch(); const history = useHistory(); - const handleEditTransaction = ({ - txData, - tokenData, - tokenProps: assetDetails, - }) => { + const handleEditTransaction = async ({ txData }) => { const { id } = txData; - dispatch( - editTransaction( - ASSET_TYPES.TOKEN, - id.toString(), - tokenData, - assetDetails, - ), - ); + await dispatch(editExistingTransaction(ASSET_TYPES.TOKEN, id.toString())); dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); }; const handleEdit = (confirmTransactionData) => { - handleEditTransaction(confirmTransactionData); - history.push(SEND_ROUTE); + handleEditTransaction(confirmTransactionData).then(() => { + history.push(SEND_ROUTE); + }); }; const conversionRate = useSelector(getConversionRate); const nativeCurrency = useSelector(getNativeCurrency); diff --git a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js index a163a13a0..45b85d799 100644 --- a/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js +++ b/ui/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.test.js @@ -3,9 +3,13 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { fireEvent } from '@testing-library/react'; -import { initialState, SEND_STATUSES } from '../../../../../ducks/send'; +import { AMOUNT_MODES, SEND_STATUSES } from '../../../../../ducks/send'; import { renderWithProvider } from '../../../../../../test/jest'; import { GAS_ESTIMATE_TYPES } from '../../../../../../shared/constants/gas'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../../../../test/jest/mocks'; import AmountMaxButton from './amount-max-button'; const middleware = [thunk]; @@ -22,7 +26,7 @@ describe('AmountMaxButton Component', () => { EIPS: {}, }, }, - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, }), ); expect(getByText('Max')).toBeTruthy(); @@ -36,12 +40,14 @@ describe('AmountMaxButton Component', () => { EIPS: {}, }, }, - send: { ...initialState, status: SEND_STATUSES.VALID }, + send: getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.VALID, + }), }); const { getByText } = renderWithProvider(, store); const expectedActions = [ - { type: 'send/updateAmountMode', payload: 'MAX' }, + { type: 'send/updateAmountMode', payload: AMOUNT_MODES.MAX }, ]; fireEvent.click(getByText('Max'), { bubbles: true }); @@ -58,9 +64,10 @@ describe('AmountMaxButton Component', () => { }, }, send: { - ...initialState, - status: SEND_STATUSES.VALID, - amount: { ...initialState.amount, mode: 'MAX' }, + ...getInitialSendStateWithExistingTxState({ + status: SEND_STATUSES.VALID, + }), + amountMode: AMOUNT_MODES.MAX, }, }); const { getByText } = renderWithProvider(, store); diff --git a/ui/pages/send/send-header/send-header.component.test.js b/ui/pages/send/send-header/send-header.component.test.js index 5ec8bdc69..a6eaa19d9 100644 --- a/ui/pages/send/send-header/send-header.component.test.js +++ b/ui/pages/send/send-header/send-header.component.test.js @@ -3,9 +3,13 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { fireEvent } from '@testing-library/react'; -import { initialState, SEND_STAGES } from '../../../ducks/send'; +import { SEND_STAGES } from '../../../ducks/send'; import { renderWithProvider } from '../../../../test/jest'; import { ASSET_TYPES } from '../../../../shared/constants/transaction'; +import { + getInitialSendStateWithExistingTxState, + INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, +} from '../../../../test/jest/mocks'; import SendHeader from './send-header.component'; const middleware = [thunk]; @@ -26,7 +30,7 @@ describe('SendHeader Component', () => { const { getByText, rerender } = renderWithProvider( , configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -35,7 +39,10 @@ describe('SendHeader Component', () => { rerender( , configureMockStore(middleware)({ - send: { ...initialState, stage: SEND_STAGES.ADD_RECIPIENT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.ADD_RECIPIENT, + }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -48,9 +55,12 @@ describe('SendHeader Component', () => { , configureMockStore(middleware)({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.DRAFT, - asset: { ...initialState.asset, type: ASSET_TYPES.NATIVE }, + asset: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT.asset, + type: ASSET_TYPES.NATIVE, + }, }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, @@ -64,9 +74,12 @@ describe('SendHeader Component', () => { , configureMockStore(middleware)({ send: { - ...initialState, + ...getInitialSendStateWithExistingTxState({ + asset: { + type: ASSET_TYPES.TOKEN, + }, + }), stage: SEND_STAGES.DRAFT, - asset: { ...initialState.asset, type: ASSET_TYPES.TOKEN }, }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, @@ -80,7 +93,7 @@ describe('SendHeader Component', () => { , configureMockStore(middleware)({ send: { - ...initialState, + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, stage: SEND_STAGES.EDIT, }, gas: { basicEstimateStatus: 'LOADING' }, @@ -96,7 +109,7 @@ describe('SendHeader Component', () => { const { getByText } = renderWithProvider( , configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -108,7 +121,10 @@ describe('SendHeader Component', () => { const { getByText } = renderWithProvider( , configureMockStore(middleware)({ - send: { ...initialState, stage: SEND_STAGES.EDIT }, + send: { + ...INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, + stage: SEND_STAGES.EDIT, + }, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }), @@ -118,7 +134,7 @@ describe('SendHeader Component', () => { it('resets send state when clicked', () => { const store = configureMockStore(middleware)({ - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, gas: { basicEstimateStatus: 'LOADING' }, history: { mostRecentOverviewPage: 'activity' }, }); diff --git a/ui/pages/send/send.js b/ui/pages/send/send.js index 79980b25c..2c17303b4 100644 --- a/ui/pages/send/send.js +++ b/ui/pages/send/send.js @@ -7,14 +7,13 @@ import { getRecipient, getRecipientUserInput, getSendStage, - initializeSendState, resetRecipientInput, resetSendState, SEND_STAGES, updateRecipient, updateRecipientUserInput, } from '../../ducks/send'; -import { getCurrentChainId, isCustomPriceExcessive } from '../../selectors'; +import { isCustomPriceExcessive } from '../../selectors'; import { getSendHexDataFeatureFlagState } from '../../ducks/metamask/metamask'; import { showQrScanner } from '../../store/actions'; import { MetaMetricsContext } from '../../contexts/metametrics'; @@ -30,7 +29,6 @@ const sendSliceIsCustomPriceExcessive = (state) => export default function SendTransactionScreen() { const history = useHistory(); - const chainId = useSelector(getCurrentChainId); const stage = useSelector(getSendStage); const gasIsExcessive = useSelector(sendSliceIsCustomPriceExcessive); const isUsingMyAccountsForRecipientSearch = useSelector( @@ -49,11 +47,8 @@ export default function SendTransactionScreen() { }, [dispatch]); useEffect(() => { - if (chainId !== undefined) { - dispatch(initializeSendState()); - window.addEventListener('beforeunload', cleanup); - } - }, [chainId, dispatch, cleanup]); + window.addEventListener('beforeunload', cleanup); + }, [cleanup]); useEffect(() => { if (location.search === '?scan=true') { diff --git a/ui/pages/send/send.test.js b/ui/pages/send/send.test.js index e2d16be9f..9df773701 100644 --- a/ui/pages/send/send.test.js +++ b/ui/pages/send/send.test.js @@ -3,11 +3,12 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import { useLocation } from 'react-router-dom'; -import { initialState, SEND_STAGES } from '../../ducks/send'; +import { SEND_STAGES } from '../../ducks/send'; import { ensInitialState } from '../../ducks/ens'; import { renderWithProvider } from '../../../test/jest'; import { RINKEBY_CHAIN_ID } from '../../../shared/constants/network'; import { GAS_ESTIMATE_TYPES } from '../../../shared/constants/gas'; +import { INITIAL_SEND_STATE_FOR_EXISTING_DRAFT } from '../../../test/jest/mocks'; import Send from './send'; const middleware = [thunk]; @@ -34,7 +35,7 @@ jest.mock( ); const baseStore = { - send: initialState, + send: INITIAL_SEND_STATE_FOR_EXISTING_DRAFT, ENS: ensInitialState, gas: { customData: { limit: null, price: null }, @@ -87,7 +88,7 @@ const baseStore = { describe('Send Page', () => { describe('Send Flow Initialization', () => { - it('should initialize the send, ENS, and gas slices on render', () => { + it('should initialize the ENS slice on render', () => { const store = configureMockStore(middleware)(baseStore); renderWithProvider(, store); const actions = store.getActions(); @@ -96,9 +97,6 @@ describe('Send Page', () => { expect.objectContaining({ type: 'ENS/enableEnsLookup', }), - expect.objectContaining({ - type: 'send/initializeSendState/pending', - }), ]), ); }); @@ -113,9 +111,6 @@ describe('Send Page', () => { expect.objectContaining({ type: 'ENS/enableEnsLookup', }), - expect.objectContaining({ - type: 'send/initializeSendState/pending', - }), expect.objectContaining({ type: 'UI_MODAL_OPEN', payload: { name: 'QR_SCANNER' }, diff --git a/ui/selectors/custom-gas.js b/ui/selectors/custom-gas.js index ea72bf9ae..3edc9b20b 100644 --- a/ui/selectors/custom-gas.js +++ b/ui/selectors/custom-gas.js @@ -8,7 +8,7 @@ import { decEthToConvertedCurrency as ethTotalToConvertedCurrency } from '../hel import { formatETHFee } from '../helpers/utils/formatters'; import { calcGasTotal } from '../pages/send/send.utils'; -import { getGasPrice } from '../ducks/send'; +import { getGasLimit, getGasPrice } from '../ducks/send'; import { GAS_ESTIMATE_TYPES as GAS_FEE_CONTROLLER_ESTIMATE_TYPES, GAS_LIMITS, @@ -321,8 +321,9 @@ export function getRenderableEstimateDataForSmallButtonsFromGWEI(state) { return []; } const showFiat = getShouldShowFiat(state); + const gasLimit = - state.send.gas.gasLimit || getCustomGasLimit(state) || GAS_LIMITS.SIMPLE; + getGasLimit(state) ?? getCustomGasLimit(state) ?? GAS_LIMITS.SIMPLE; const { conversionRate } = state.metamask; const currentCurrency = getCurrentCurrency(state); const gasFeeEstimates = getGasFeeEstimates(state); diff --git a/ui/selectors/custom-gas.test.js b/ui/selectors/custom-gas.test.js index d41ec27c3..047335282 100644 --- a/ui/selectors/custom-gas.test.js +++ b/ui/selectors/custom-gas.test.js @@ -1,4 +1,5 @@ import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../shared/constants/gas'; +import { getInitialSendStateWithExistingTxState } from '../../test/jest/mocks'; import { getCustomGasLimit, getCustomGasPrice, @@ -11,7 +12,9 @@ import { describe('custom-gas selectors', () => { describe('getCustomGasPrice()', () => { it('should return gas.customData.price', () => { - const mockState = { gas: { customData: { price: 'mockPrice' } } }; + const mockState = { + gas: { customData: { price: 'mockPrice' } }, + }; expect(getCustomGasPrice(mockState)).toStrictEqual('mockPrice'); }); }); @@ -200,11 +203,11 @@ describe('custom-gas selectors', () => { EIPS: {}, }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: '0x28bed0160', }, - }, + }), gas: { customData: { price: null }, }, @@ -222,11 +225,11 @@ describe('custom-gas selectors', () => { EIPS: {}, }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasPrice: '0x30e4f9b400', }, - }, + }), gas: { customData: { price: null }, }, @@ -330,11 +333,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -379,11 +382,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -428,11 +431,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -477,11 +480,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, ]; @@ -542,11 +545,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -591,11 +594,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -640,11 +643,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -689,11 +692,11 @@ describe('custom-gas selectors', () => { chainId: '0x4', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, { @@ -738,11 +741,11 @@ describe('custom-gas selectors', () => { chainId: '0x1', }, }, - send: { + send: getInitialSendStateWithExistingTxState({ gas: { gasLimit: GAS_LIMITS.SIMPLE, }, - }, + }), }, }, ]; diff --git a/ui/store/actions.js b/ui/store/actions.js index 15a80d493..10893ad33 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -27,7 +27,11 @@ import { getNotifications, ///: END:ONLY_INCLUDE_IN } from '../selectors'; -import { computeEstimatedGasLimit, resetSendState } from '../ducks/send'; +import { + computeEstimatedGasLimit, + initializeSendState, + resetSendState, +} from '../ducks/send'; import { switchedToUnconnectedAccount } from '../ducks/alerts/unconnected-account'; import { getUnconnectedAccountAlertEnabledness } from '../ducks/metamask/metamask'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; @@ -1443,6 +1447,11 @@ export function updateMetamaskState(newState) { type: actionConstants.CHAIN_CHANGED, payload: newProvider.chainId, }); + // We dispatch this action to ensure that the send state stays up to date + // after the chain changes. This async thunk will fail gracefully in the + // event that we are not yet on the send flow with a draftTransaction in + // progress. + dispatch(initializeSendState({ chainHasChanged: true })); } dispatch({ type: actionConstants.UPDATE_METAMASK_STATE,