import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; import BigNumber from 'bignumber.js'; import { addHexPrefix } from 'ethereumjs-util'; import { debounce } from 'lodash'; import { v4 as uuidv4 } from 'uuid'; import { conversionGreaterThan, conversionUtil, multiplyCurrencies, subtractCurrencies, } from '../../../shared/modules/conversion.utils'; import { GAS_ESTIMATE_TYPES, GAS_LIMITS } from '../../../shared/constants/gas'; import { CONTRACT_ADDRESS_ERROR, INSUFFICIENT_FUNDS_ERROR, INSUFFICIENT_TOKENS_ERROR, INVALID_RECIPIENT_ADDRESS_ERROR, INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR, KNOWN_RECIPIENT_ADDRESS_WARNING, NEGATIVE_ETH_ERROR, } from '../../pages/send/send.constants'; import { calcGasTotal, isBalanceSufficient, isTokenBalanceSufficient, } from '../../pages/send/send.utils'; import { getAdvancedInlineGasShown, getCurrentChainId, getGasPriceInHexWei, getIsMainnet, getTargetAccount, getIsNonStandardEthChain, checkNetworkAndAccountSupports1559, getUseTokenDetection, getTokenList, getAddressBookEntryOrAccountName, getIsMultiLayerFeeNetwork, getEnsResolutionByAddress, getSelectedAccount, getSelectedAddress, } from '../../selectors'; import { disconnectGasFeeEstimatePoller, displayWarning, getGasFeeEstimatesAndStartPolling, hideLoadingIndication, showLoadingIndication, updateEditableParams, updateTransactionGasFees, addPollingTokenToAppState, removePollingTokenFromAppState, isCollectibleOwner, getTokenStandardAndDetails, showModal, addUnapprovedTransactionAndRouteToConfirmationPage, updateTransactionSendFlowHistory, } from '../../store/actions'; import { setCustomGasLimit } from '../gas/gas.duck'; import { QR_CODE_DETECTED, SELECTED_ACCOUNT_CHANGED, ACCOUNT_CHANGED, ADDRESS_BOOK_UPDATED, GAS_FEE_ESTIMATES_UPDATED, } from '../../store/actionConstants'; import { calcTokenAmount, getTokenAddressParam, getTokenValueParam, getTokenMetadata, getTokenIdParam, } from '../../helpers/utils/token-util'; import { checkExistingAddresses, isDefaultMetaMaskChain, isOriginContractAddress, isValidDomainName, } from '../../helpers/utils/util'; import { getGasEstimateType, getTokens, getUnapprovedTxs, } from '../metamask/metamask'; import { resetEnsResolution } from '../ens'; import { isBurnAddress, isValidHexAddress, toChecksumHexAddress, } from '../../../shared/modules/hexstring-utils'; import { isSmartContractAddress, sumHexes, } from '../../helpers/utils/transactions.util'; import fetchEstimatedL1Fee from '../../helpers/utils/optimism/fetchEstimatedL1Fee'; import { ETH } from '../../helpers/constants/common'; import { ASSET_TYPES, TOKEN_STANDARDS, TRANSACTION_ENVELOPE_TYPES, TRANSACTION_TYPES, } from '../../../shared/constants/transaction'; 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 {( * import('immer/dist/internal').WritableDraft * )} SendStateDraft * @typedef {( * import('../../../shared/constants/transaction').AssetTypesString * )} AssetTypesString * @typedef {( * import( '../../helpers/constants/common').TokenStandardStrings * )} TokenStandardStrings * @typedef {( * import( '../../../shared/constants/tokens').TokenDetails * )} TokenDetails * @typedef {( * import('../../../shared/constants/transaction').TransactionTypeString * )} TransactionTypeString * @typedef {( * import('@metamask/controllers').LegacyGasPriceEstimate * )} LegacyGasPriceEstimate * @typedef {( * import('@metamask/controllers').GasFeeEstimates * )} GasFeeEstimates * @typedef {( * import('@metamask/controllers').EthGasPriceEstimate * )} EthGasPriceEstimate * @typedef {( * import('@metamask/controllers').GasEstimateType * )} GasEstimateType * @typedef {( * import('redux').AnyAction * )} AnyAction */ /** * @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 {'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 * be sent to the Transaction Controller. * @property {'EDIT'} EDIT - The send form is shown for a transaction already * 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. * @property {'INACTIVE'} INACTIVE - The send state is idle, and hasn't yet * fetched required data for gasPrice and gasLimit estimations, etc. */ /** * The Stages that the send slice can be in * * @type {SendStateStages} */ export const SEND_STAGES = { ADD_RECIPIENT: 'ADD_RECIPIENT', DRAFT: 'DRAFT', EDIT: 'EDIT', INACTIVE: 'INACTIVE', }; /** * @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: * 1. The recipient is not yet defined * 2. The amount + gasTotal is greater than the user's balance when sending * native currency * 3. The gasTotal is greater than the user's *native* balance * 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) * @property {'VALID'} VALID - The transaction is valid and can be submitted. */ /** * The status of the send slice * * @type {DraftTxStatus} */ export const SEND_STATUSES = { 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 {'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). * @property {'INLINE'} INLINE - Shows inline gasLimit/gasPrice fields when on * any other network or metaswaps API fails and we use eth_gasPrice. */ /** * Controls what is displayed in the send-gas-row component. * * @type {SendStateGasModes} */ export const GAS_INPUT_MODES = { BASIC: 'BASIC', CUSTOM: 'CUSTOM', INLINE: 'INLINE', }; /** * @typedef {object} SendStateAmountModes * @property {'INPUT'} INPUT - the user provides the amount by typing in the * field. * @property {'MAX'} MAX - The user selects the MAX button and amount is * calculated based on balance - (amount + gasTotal). */ /** * The modes that the amount field can be set by * * @type {SendStateAmountModes} */ export const AMOUNT_MODES = { INPUT: 'INPUT', MAX: 'MAX', }; /** * @typedef {object} SendStateRecipientModes * @property {'CONTACT_LIST'} CONTACT_LIST - The user is displayed a list of * their contacts and addresses they have recently send to. * @property {'MY_ACCOUNTS'} MY_ACCOUNTS - the user is displayed a list of * their own accounts to send to. */ /** * The type of recipient list that is displayed to user * * @type {SendStateRecipientModes} */ export const RECIPIENT_SEARCH_MODES = { CONTACT_LIST: 'CONTACT_LIST', MY_ACCOUNTS: 'MY_ACCOUNTS', }; /** * @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. */ /** * @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. */ /** * @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'. */ /** * @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. */ /** * 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. */ /** * @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. */ /** * @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, recipientWarningAcknowledged: false, }, status: SEND_STATUSES.VALID, transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, userInputHexData: null, }; /** * 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. */ /** * @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, }; /** * 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. */ 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 // that necessitate this logic are listed below: // 1. when the amount sent changes when sending a token due to the amount being // part of the hex encoded data property of the transaction. // 2. when updating the data property while sending NATIVE currency (ex: ETH) // because the data parameter defines function calls that the EVM will have // to execute which is where a large chunk of gas is potentially consumed. // 3. when the recipient changes while sending a token due to the recipient's // address being included in the hex encoded data property of the // transaction // 4. when the asset being sent changes due to the contract address and details // of the token being included in the hex encoded data property of the // transaction. If switching to NATIVE currency (ex: ETH), the gasLimit will // change due to hex data being removed (unless supplied by user). // This method computes the gasLimit estimate which is written to state in an // action handler in extraReducers. export const computeEstimatedGasLimit = createAsyncThunk( 'send/computeEstimatedGasLimit', 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[draftTransaction.id]; const isNonStandardEthChain = getIsNonStandardEthChain(state); const chainId = getCurrentChainId(state); let gasTotalForLayer1; if (isMultiLayerFeeNetwork) { gasTotalForLayer1 = await fetchEstimatedL1Fee(global.eth, { txParams: { gasPrice: draftTransaction.gas.gasPrice, gas: draftTransaction.gas.gasLimit, to: draftTransaction.recipient.address?.toLowerCase(), value: send.amountMode === AMOUNT_MODES.MAX ? send.selectedAccount.balance : draftTransaction.amount.value, from: send.selectedAccount.address, data: draftTransaction.userInputHexData, type: '0x0', }, }); } if ( send.stage !== SEND_STAGES.EDIT || !transaction.dappSuggestedGasFees?.gas || !transaction.userEditedGasLimit ) { const gasLimit = await estimateGasLimitForSend({ gasPrice: draftTransaction.gas.gasPrice, blockGasLimit: metamask.currentBlockGasLimit, selectedAddress: metamask.selectedAddress, sendToken: draftTransaction.asset.details, to: draftTransaction.recipient.address?.toLowerCase(), value: draftTransaction.amount.value, data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, gasLimit: draftTransaction.gas.gasLimit, }); await thunkApi.dispatch(setCustomGasLimit(gasLimit)); return { gasLimit, gasTotalForLayer1, }; } return null; }, ); /** * @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. */ /** * Responsible for initializing required state for the send slice. * This method is dispatched from the send page in the componentDidMount * method. It is also dispatched anytime the network changes to ensure that * the slice remains valid with changing token and account balances. To do so * it keys into state to get necessary values and computes a starting point for * the send slice. It returns the values that might change from this action and * those values are written to the slice in the `initializeSendState.fulfilled` * action handler. */ export const initializeSendState = createAsyncThunk( 'send/initializeSendState', 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 account = getSelectedAccount(state); const { send: sendState, metamask } = state; const draftTransaction = sendState.draftTransactions[sendState.currentTransactionUUID]; // 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) { return 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 = sendState.stage === SEND_STAGES.EDIT ? draftTransaction.gas.gasPrice : '0x1'; let gasEstimatePollToken = null; // Instruct the background process that polling for gas prices should begin gasEstimatePollToken = await getGasFeeEstimatesAndStartPolling(); addPollingTokenToAppState(gasEstimatePollToken); const { metamask: { gasFeeEstimates, gasEstimateType }, } = thunkApi.getState(); 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 } = draftTransaction.gas; if ( gasEstimateType !== GAS_ESTIMATE_TYPES.NONE && 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: draftTransaction.fromAccount?.address ?? sendState.selectedAccount.address, sendToken: draftTransaction.asset.details, to: draftTransaction.recipient.address.toLowerCase(), value: draftTransaction.amount.value, data: draftTransaction.userInputHexData, isNonStandardEthChain, chainId, }); gasLimit = estimatedGasLimit || gasLimit; } // 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)); // There may be a case where the send has been canceled by the user while // the gas estimate is being computed. So we check again to make sure that // a currentTransactionUUID exists and matches the previous tx. const newState = thunkApi.getState(); if ( newState.send.currentTransactionUUID !== sendState.currentTransactionUUID ) { return thunkApi.rejectWithValue( `draftTransaction changed during initialization. A new initializeSendState action must be dispatched.`, ); } return { account, chainId: getCurrentChainId(state), tokens: getTokens(state), chainHasChanged, gasFeeEstimates, gasEstimateType, gasLimit, gasTotal: addHexPrefix(calcGasTotal(gasLimit, gasPrice)), gasEstimatePollToken, eip1559support, useTokenDetection: getUseTokenDetection(state), tokenAddressList: Object.keys(getTokenList(state)), }; }, ); // Action Payload Typedefs /** * @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 */ /** * @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 */ /** * @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 */ /** * @typedef {( * import('@reduxjs/toolkit').PayloadAction * )} UpdateAssetPayload * @typedef {( * import('@reduxjs/toolkit').PayloadAction * )} DraftTransactionPayload */ const slice = createSlice({ name, initialState, reducers: { /** * 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 {DraftTransactionPayload} action - An action with payload that is * a new draft transaction that will be added to state. * @returns {void} */ 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 * revalidate the field and form. * * @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 (draftTransaction.asset.type === ASSET_TYPES.TOKEN) { const decimals = draftTransaction.asset.details?.decimals ?? 0; const multiplier = Math.pow(10, Number(decimals)); amount = multiplyCurrencies( draftTransaction.asset.balance, multiplier, { toNumericBase: 'hex', multiplicandBase: 16, multiplierBase: 10, }, ); } else { const _gasTotal = sumHexes( draftTransaction.gas.gasTotal || '0x0', state.gasTotalForLayer1 || '0x0', ); amount = subtractCurrencies( addHexPrefix(draftTransaction.asset.balance), addHexPrefix(_gasTotal), { toNumericBase: 'hex', aBase: 16, bBase: 16, }, ); } slice.caseReducers.updateSendAmount(state, { payload: amount, }); }, /** * Updates the currently selected asset * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. * @param {UpdateAssetPayload} action - The asset to set in the * draftTransaction. * @returns {void} */ updateAsset: (state, action) => { const { asset, initialAssetSet } = action.payload; const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; draftTransaction.asset.type = asset.type; draftTransaction.asset.balance = asset.balance; draftTransaction.asset.error = asset.error; if ( draftTransaction.asset.type === ASSET_TYPES.TOKEN || draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ) { draftTransaction.asset.details = asset.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 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 (initialAssetSet === false) { slice.caseReducers.updateSendAmount(state, { payload: '0x0' }); } // validate send state slice.caseReducers.validateSendState(state); }, /** * 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 {GasEstimateUpdatePayload)} action - The gas fee update payload * @returns {void} */ updateGasFeeEstimates: (state, action) => { const { gasFeeEstimates, gasEstimateType } = action.payload; let gasPriceEstimate = '0x0'; switch (gasEstimateType) { case GAS_ESTIMATE_TYPES.FEE_MARKET: slice.caseReducers.updateGasFees(state, { payload: { transactionType: TRANSACTION_ENVELOPE_TYPES.FEE_MARKET, maxFeePerGas: getGasPriceInHexWei( gasFeeEstimates.medium.suggestedMaxFeePerGas, ), maxPriorityFeePerGas: getGasPriceInHexWei( gasFeeEstimates.medium.suggestedMaxPriorityFeePerGas, ), }, }); break; case GAS_ESTIMATE_TYPES.LEGACY: gasPriceEstimate = getRoundedGasPrice(gasFeeEstimates.medium); slice.caseReducers.updateGasFees(state, { payload: { gasPrice: gasPriceEstimate, type: TRANSACTION_ENVELOPE_TYPES.LEGACY, isAutomaticUpdate: true, }, }); break; case GAS_ESTIMATE_TYPES.ETH_GASPRICE: gasPriceEstimate = getRoundedGasPrice(gasFeeEstimates.gasPrice); slice.caseReducers.updateGasFees(state, { payload: { gasPrice: getRoundedGasPrice(gasFeeEstimates.gasPrice), type: TRANSACTION_ENVELOPE_TYPES.LEGACY, isAutomaticUpdate: true, }, }); break; case GAS_ESTIMATE_TYPES.NONE: default: break; } // Record the latest gasPriceEstimate for future comparisons 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]; if (draftTransaction) { 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 {SimpleStringPayload} action - the * gasTotalForLayer1 to set in hex wei. * @returns {void} */ updateLayer1Fees: (state, action) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; state.gasTotalForLayer1 = action.payload; if ( state.amountMode === AMOUNT_MODES.MAX && draftTransaction.asset.type === ASSET_TYPES.NATIVE ) { slice.caseReducers.updateAmountToMax(state); } }, /** * Updates the recipient of the draftTransaction * * @param {SendStateDraft} state - A writable draft of the send state to be * updated. * @param {updateRecipientPayload} action - The recipient to set in the * draftTransaction. * @returns {void} */ updateRecipient: (state, action) => { 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 (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; } else { // 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 = draftTransaction.id === null ? SEND_STAGES.DRAFT : SEND_STAGES.EDIT; state.recipientMode = RECIPIENT_SEARCH_MODES.CONTACT_LIST; } // validate send state slice.caseReducers.validateSendState(state); }, /** * 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; }, updateRecipientWarning: (state, action) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; draftTransaction.recipient.warning = action.payload; }, updateDraftTransactionStatus: (state, action) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; draftTransaction.status = action.payload; }, acknowledgeRecipientWarning: (state) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; draftTransaction.recipient.recipientWarningAcknowledged = true; slice.caseReducers.validateSendState(state); }, /** * Updates the value of the recipientInput key with what the user has * typed into the recipient input field in the UI. * * @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.recipientInput = action.payload; }, /** * 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); }, /** * 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; }, /** * 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 draftTransaction.asset.type === ASSET_TYPES.NATIVE && !isBalanceSufficient({ amount: draftTransaction.amount.value, balance: draftTransaction.asset.balance, gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }): 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 draftTransaction.asset.type === ASSET_TYPES.TOKEN && !isTokenBalanceSufficient({ tokenBalance: draftTransaction.asset.balance ?? '0x0', amount: draftTransaction.amount.value, decimals: draftTransaction.asset.details.decimals, }): 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: draftTransaction.amount.value, fromNumericBase: 'hex' }, ): draftTransaction.amount.error = NEGATIVE_ETH_ERROR; break; // If none of the above are true, set error to null default: 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) => { const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; const insufficientFunds = !isBalanceSufficient({ amount: draftTransaction.asset.type === ASSET_TYPES.NATIVE ? draftTransaction.amount.value : '0x0', balance: draftTransaction.fromAccount?.balance ?? state.selectedAccount.balance, gasTotal: draftTransaction.gas.gasTotal ?? '0x0', }); 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 { chainId, tokens, tokenAddressList, isProbablyAnAssetContract, } = 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 ( isOriginContractAddress( state.recipientInput, draftTransaction.asset?.details?.address, ) ) { draftTransaction.recipient.error = CONTRACT_ADDRESS_ERROR; } else { draftTransaction.recipient.error = null; } if ( (isValidHexAddress(state.recipientInput) && (tokenAddressList.find((address) => isEqualCaseInsensitive(address, state.recipientInput), ) || checkExistingAddresses(state.recipientInput, tokens))) || isProbablyAnAssetContract ) { draftTransaction.recipient.warning = KNOWN_RECIPIENT_ADDRESS_WARNING; } else { draftTransaction.recipient.warning = null; } } } slice.caseReducers.validateSendState(state); }, /** * 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]; if (draftTransaction) { switch (true) { 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.gasEstimateIsLoading: case new BigNumber(draftTransaction.gas.gasLimit, 16).lessThan( new BigNumber(state.gasLimitMinimum), ): draftTransaction.status = SEND_STATUSES.INVALID; break; case draftTransaction.recipient.warning === 'loading': case draftTransaction.recipient.warning === KNOWN_RECIPIENT_ADDRESS_WARNING && draftTransaction.recipient.recipientWarningAcknowledged === false: draftTransaction.status = SEND_STATUSES.INVALID; break; default: draftTransaction.status = SEND_STATUSES.VALID; } } }, }, extraReducers: (builder) => { builder .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.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.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. 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); slice.caseReducers.validateSendState(state); } }) .addCase(ADDRESS_BOOK_UPDATED, (state, action) => { // When the address book updates from background state changes we need // to check to see if an entry exists for the current address or if the // entry changed. const { addressBook } = action.payload; 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 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.selectedAccount.address = action.payload.account.address; state.selectedAccount.balance = action.payload.account.balance; const draftTransaction = state.draftTransactions[state.currentTransactionUUID]; if (draftTransaction) { draftTransaction.gas.gasLimit = action.payload.gasLimit; draftTransaction.gas.gasTotal = action.payload.gasTotal; 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; } } slice.caseReducers.updateGasFeeEstimates(state, { payload: { gasFeeEstimates: action.payload.gasFeeEstimates, gasEstimateType: action.payload.gasEstimateType, }, }); state.gasEstimatePollToken = action.payload.gasEstimatePollToken; if (action.payload.gasEstimatePollToken) { state.gasEstimateIsLoading = false; } if (state.stage !== SEND_STAGES.INACTIVE) { slice.caseReducers.validateRecipientUserInput(state, { payload: { chainId: action.payload.chainId, tokens: action.payload.tokens, useTokenDetection: action.payload.useTokenDetection, tokenAddressList: action.payload.tokenAddressList, }, }); } slice.caseReducers.validateAmountField(state); slice.caseReducers.validateGasField(state); slice.caseReducers.validateSendState(state); }) .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(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; } } } }); }, }); const { actions, reducer } = slice; export default reducer; const { useDefaultGas, useCustomGas, updateGasLimit, validateRecipientUserInput, updateRecipientSearchMode, addHistoryEntry, acknowledgeRecipientWarning, } = actions; export { useDefaultGas, useCustomGas, updateGasLimit, addHistoryEntry, acknowledgeRecipientWarning, }; // 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, resolve) => { dispatch( addHistoryEntry( `sendFlow - user typed ${payload.userInput} into recipient input field`, ), ); dispatch(validateRecipientUserInput(payload)); resolve(); }, 300, ); /** * Begins a new draft transaction, derived from the txParams of an existing * 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 }, { initialAssetSet: 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: getTokenIdParam(tokenData) ?? getTokenValueParam(tokenData), } : {}), }, }, { initialAssetSet: 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 * send flow based on user input, it'll just be a shallow copy of the current * estimate. This method is necessary because the internal structure of this * slice has been changed such that it is agnostic to transaction envelope * type, and this method calls into the new structure in the appropriate way. * * @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) => { dispatch( addHistoryEntry(`sendFlow - user set legacy gasPrice to ${gasPrice}`), ); dispatch( actions.updateGasFees({ gasPrice, transactionType: TRANSACTION_ENVELOPE_TYPES.LEGACY, }), ); }; } /** * 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 {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 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 nicknameFromAddressBookEntryOrAccountName = getAddressBookEntryOrAccountName(state, address) ?? ''; await dispatch( actions.updateRecipient({ address, nickname: nickname || nicknameFromAddressBookEntryOrAccountName, }), ); await dispatch(computeEstimatedGasLimit()); }; } /** * 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 * version of the validateRecipientUserInput action. This way validation only * occurs once the user has stopped typing. * * @param {string} userInput - the value that the user is typing into the field */ export function updateRecipientUserInput(userInput) { return async (dispatch, getState) => { dispatch(actions.updateRecipientWarning('loading')); dispatch(actions.updateDraftTransactionStatus(SEND_STATUSES.INVALID)); await dispatch(actions.updateRecipientUserInput(userInput)); const state = getState(); const draftTransaction = state[name].draftTransactions[state[name].currentTransactionUUID]; const sendingAddress = draftTransaction.fromAccount?.address ?? state[name].selectedAccount.address ?? getSelectedAddress(state); const chainId = getCurrentChainId(state); const tokens = getTokens(state); const useTokenDetection = getUseTokenDetection(state); const tokenMap = getTokenList(state); const tokenAddressList = Object.keys(tokenMap); const inputIsValidHexAddress = isValidHexAddress(userInput); let isProbablyAnAssetContract = false; if (inputIsValidHexAddress) { const smartContractAddress = await isSmartContractAddress(userInput); if (smartContractAddress) { const { symbol, decimals } = getTokenMetadata(userInput, tokenMap) || {}; isProbablyAnAssetContract = symbol && decimals !== undefined; if (!isProbablyAnAssetContract) { try { const { standard } = await getTokenStandardAndDetails( userInput, sendingAddress, ); isProbablyAnAssetContract = Boolean(standard); } catch (e) { console.log(e); } } } } return new Promise((resolve) => { debouncedValidateRecipientUserInput( dispatch, { userInput, chainId, tokens, useTokenDetection, tokenAddressList, isProbablyAnAssetContract, }, resolve, ); }); }; } /** * 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 {string} amount - hex string representing value * @returns {ThunkAction} */ export function updateSendAmount(amount) { return async (dispatch, getState) => { const state = getState(); 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( 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()); }; } /** * 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 updateSendAsset( { type, details: providedDetails }, { initialAssetSet = 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) { const unapprovedTxs = getUnapprovedTxs(state); const unapprovedTx = unapprovedTxs?.[draftTransaction.id]; await dispatch( addHistoryEntry( `sendFlow - user set asset of type ${ ASSET_TYPES.NATIVE } with symbol ${state.metamask.provider?.ticker ?? ETH}`, ), ); await dispatch( actions.updateAsset({ asset: { type, details: null, balance: account.balance, error: null, }, initialAssetSet, }), ); // This is meant to handle cases where we are editing an unapprovedTx from the background state // and its type is a token method. In such a case, the hex data will be the necessary hex data // for calling the contract transfer method. // Now that we are updating the transaction to be a send of a native asset type, we should // set the hex data of the transaction being editing to be empty. // then the user will not want to send any hex data now that they have change the if ( unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM || unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER || unapprovedTx?.type === TRANSACTION_TYPES.TOKEN_METHOD_SAFE_TRANSFER_FROM ) { await dispatch(actions.updateUserInputHexData('')); } } else { await dispatch(showLoadingIndication()); const details = { ...providedDetails, ...(await getTokenStandardAndDetails( providedDetails.address, sendingAddress, providedDetails.tokenId, )), }; await dispatch(hideLoadingIndication()); const asset = { type, details, error: null, }; if (details.standard === TOKEN_STANDARDS.ERC20) { asset.balance = addHexPrefix( calcTokenAmount(details.balance, details.decimals).toString(16), ); await dispatch( addHistoryEntry( `sendFlow - user set asset to ERC20 token with symbol ${details.symbol} and address ${details.address}`, ), ); } else 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}`, ), ); } } await dispatch(actions.updateAsset({ asset, initialAssetSet })); } if (initialAssetSet === false) { await dispatch(computeEstimatedGasLimit()); } }; } /** * When a user has enabled hex data field in advanced settings they will be * able to supply hex data on a transaction. This method updates the user * supplied data. Note, when sending native assets this will result in * recomputing estimated gasLimit. When sending a ERC20 asset this is not done * because the data sent in the transaction will be determined by the asset, * 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) => { await dispatch( addHistoryEntry(`sendFlow - user added custom hexData ${hexData}`), ); await dispatch(actions.updateUserInputHexData(hexData)); const state = getState(); const draftTransaction = state[name].draftTransactions[state[name].currentTransactionUUID]; if (draftTransaction.asset.type === ASSET_TYPES.NATIVE) { await dispatch(computeEstimatedGasLimit()); } }; } /** * Sets the recipient search mode to show a list of the user's contacts and * recently interacted with addresses. * * @returns {ThunkAction} */ 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(); dispatch(actions.resetSendState()); if (state[name].gasEstimatePollToken) { await disconnectGasFeeEstimatePoller(state[name].gasEstimatePollToken); removePollingTokenFromAppState(state[name].gasEstimatePollToken); } }; } /** * Signs a transaction or updates a transaction in state if editing. * This method is called when a user clicks the next button in the footer of * the send page, signaling that a transaction should be executed. This method * 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 { 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. // We first must grab the previous transaction object from state and then // 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[draftTransaction.id]; // We only update the tx params that can be changed via the edit flow UX const eip1559OnlyTxParamsToUpdate = { data: txParams.data, from: txParams.from, to: txParams.to, value: txParams.value, gas: unapprovedTx.userEditedGasLimit ? unapprovedTx.txParams.gas : txParams.gas, }; unapprovedTx.originalGasEstimate = eip1559OnlyTxParamsToUpdate.gas; const editingTx = { ...unapprovedTx, txParams: Object.assign( unapprovedTx.txParams, eip1559support ? eip1559OnlyTxParamsToUpdate : txParams, ), }; await dispatch( addHistoryEntry( `sendFlow - user clicked next and transaction should be updated in controller`, ), ); await dispatch( updateTransactionSendFlowHistory( draftTransaction.id, draftTransaction.history, ), ); await dispatch( updateEditableParams(draftTransaction.id, editingTx.txParams), ); await dispatch( updateTransactionGasFees(draftTransaction.id, editingTx.txParams), ); } else { let transactionType = TRANSACTION_TYPES.SIMPLE_SEND; if (draftTransaction.asset.type !== ASSET_TYPES.NATIVE) { transactionType = draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE ? TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER_FROM : TRANSACTION_TYPES.TOKEN_METHOD_TRANSFER; } await dispatch( addHistoryEntry( `sendFlow - user clicked next and transaction should be added to controller`, ), ); dispatch( addUnapprovedTransactionAndRouteToConfirmationPage( txParams, transactionType, draftTransaction.history, ), ); } }; } /** * 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(); 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)] ?? {}; } /** * Selector that returns true if a draft transaction exists. * * @type {Selector} */ export function getDraftTransactionExists(state) { const draftTransaction = getCurrentDraftTransaction(state); if (Object.keys(draftTransaction).length === 0) { return false; } return true; } // Gas selectors /** * Selector that returns the current draft transaction's gasLimit. * * @type {Selector} */ export function getGasLimit(state) { return getCurrentDraftTransaction(state).gas?.gasLimit; } /** * Selector that returns the current draft transaction's gasPrice. * * @type {Selector} */ export function getGasPrice(state) { return getCurrentDraftTransaction(state).gas?.gasPrice; } /** * Selector that returns the current draft transaction's gasTotal. * * @type {Selector} */ export function getGasTotal(state) { return getCurrentDraftTransaction(state).gas?.gasTotal; } /** * Selector that returns the error, if present, for the gas fields. * * @type {Selector} */ export function gasFeeIsInError(state) { 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].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].gasIsSetInModal) { return GAS_INPUT_MODES.CUSTOM; } if ((!isMainnet && !process.env.IN_TEST) || showAdvancedGasFields) { return GAS_INPUT_MODES.INLINE; } // We get eth_gasPrice estimation if the legacy API fails but we need to // instruct the UI to render the INLINE inputs in this case, only on // mainnet or IN_TEST. if ( (isMainnet || process.env.IN_TEST) && gasEstimateType === GAS_ESTIMATE_TYPES.ETH_GASPRICE ) { return GAS_INPUT_MODES.INLINE; } return GAS_INPUT_MODES.BASIC; } // Asset Selectors /** * Selector that returns the asset the current draft transaction is sending. * * @type {Selector} */ export function getSendAsset(state) { 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 (getSendAsset(state)?.type === ASSET_TYPES.NATIVE) { return true; } return getSendAsset(state)?.details?.isERC721 === false; } /** * Selector that returns the asset error if it exists. * * @type {Selector} */ export function getAssetError(state) { return getSendAsset(state).error; } // Amount Selectors /** * Selector that returns the amount that current draft transaction is sending. * * @type {Selector} */ export function getSendAmount(state) { 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 ( 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].amountMode === AMOUNT_MODES.MAX; } /** * Selector that returns the current draft transaction's data field. * * @type {Selector} */ export function getSendHexData(state) { return getCurrentDraftTransaction(state).userInputHexData; } /** * Selector that returns the current draft transaction's id, if present. * * @type {Selector} */ export function getDraftTransactionID(state) { 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(getCurrentDraftTransaction(state).amount?.error); } // Recipient Selectors /** * Selector that returns the current draft transaction's recipient. * * @type {Selector} */ export function getRecipient(state) { 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 { ...draft.recipient, nickname: draft.recipient.nickname || getEnsResolutionByAddress(state, checksummedAddress), }; } 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; } export function getRecipientWarningAcknowledgement(state) { return ( getCurrentDraftTransaction(state).recipient?.recipientWarningAcknowledged ?? false ); } // 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: 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) { const draftTransaction = getCurrentDraftTransaction(state); if (!draftTransaction) { return true; } return draftTransaction.status === SEND_STATUSES.INVALID; } /** * Selector that returns the current stage of the send flow * * @type {Selector>} */ export function getSendStage(state) { return state[name].stage; }