1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-10-23 03:36:18 +02:00
metamask-extension/ui/ducks/send/send.js
Dan J Miller 0c163dd8aa
Show users a warning when they are sending directly to a token contract (#13588)
* Fix warning dialog when sending tokens to a known token contract address

Fixing after rebase

Covering missed cases

Rebased and ran yarn setup

Rebased

Fix checkContractAddress condition

Lint fix

Applied requested changes

Fix unit tests

Applying requested changes

Applied requested changes

Refactor and update

Lint fix

Use V2 of ActionableMessage component

Adding Learn More Link

Updating warning copy

Addressing review feedback

Fix up copy changes

Simplify validation of pasted addresses

Improve detection of whether this is a token contract

Refactor to leave updateRecipient unchanged, and to prevent the double calling of update recipient

Update tests

fix

* Fix unit tests

* Fix e2e tests

* Ensure next button is disabled while recipient type is loading

* Add optional chaining and a fallback to getRecipientWarningAcknowledgement

* Fix lint

* Don't reset recipient warning on asset change, because we should show recipient warnings regardless of asset

* Update unit tests

* Update unit tests

Co-authored-by: Filip Sekulic <filip.sekulic@consensys.net>
2022-07-13 19:45:38 -02:30

2609 lines
91 KiB
JavaScript

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,
} 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 { sumHexes } from '../../helpers/utils/transactions.util';
import fetchEstimatedL1Fee from '../../helpers/utils/optimism/fetchEstimatedL1Fee';
import { TOKEN_STANDARDS, ETH } from '../../helpers/constants/common';
import {
ASSET_TYPES,
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<SendState>
* )} 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<R, MetaMaskState, unknown, AnyAction>
* )} ThunkAction<R>
*/
/**
* 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<O>
*/
/**
* @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<DraftTxStatus>} 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<SendStateAmountModes>} 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.<string, DraftTransaction>} 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<SendStateRecipientModes>} 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<SendStateStages>} 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
: send.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) {
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));
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<string>
* )} SimpleStringPayload
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<MapValuesToUnion<SendStateAmountModes>>
* )} SendStateAmountModePayload
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<DraftTransaction['asset']>
* )} UpdateAssetPayload
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<Partial<
* Pick<DraftTransaction['recipient'], 'address' | 'nickname'>>
* >
* )} updateRecipientPayload
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<SendState['recipientMode']>
* )} 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<GasFeeUpdateParams>
* )} 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<GasEstimateUpdateParams>
* )} GasEstimateUpdatePayload
*/
/**
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<DraftTransaction['asset']>
* )} UpdateAssetPayload
* @typedef {(
* import('@reduxjs/toolkit').PayloadAction<DraftTransaction>
* )} 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 asest to set in the
* draftTransaction.
* @returns {void}
*/
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 (
draftTransaction.asset.type === ASSET_TYPES.TOKEN ||
draftTransaction.asset.type === ASSET_TYPES.COLLECTIBLE
) {
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 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' });
}
// 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];
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];
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];
draftTransaction.gas.gasLimit = action.payload.gasLimit;
slice.caseReducers.updateGasFeeEstimates(state, {
payload: {
gasFeeEstimates: action.payload.gasFeeEstimates,
gasEstimateType: action.payload.gasEstimateType,
},
});
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.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<void>}
*/
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
* 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<void>}
*/
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<void>}
*/
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 { 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<void>}
*/
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<void>}
*/
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());
}
};
}
/**
* 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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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,
),
);
dispatch(updateEditableParams(draftTransaction.id, editingTx.txParams));
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<void>}
*/
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, 'type' | 'details'>} 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<void>}
*/
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<string>}
*/
export function getCurrentTransactionUUID(state) {
return state[name].currentTransactionUUID;
}
/**
* Selector that returns the current draft transaction.
*
* @type {Selector<DraftTransaction>}
*/
export function getCurrentDraftTransaction(state) {
return state[name].draftTransactions[getCurrentTransactionUUID(state)] ?? {};
}
// Gas selectors
/**
* Selector that returns the current draft transaction's gasLimit.
*
* @type {Selector<?string>}
*/
export function getGasLimit(state) {
return getCurrentDraftTransaction(state).gas?.gasLimit;
}
/**
* Selector that returns the current draft transaction's gasPrice.
*
* @type {Selector<?string>}
*/
export function getGasPrice(state) {
return getCurrentDraftTransaction(state).gas?.gasPrice;
}
/**
* Selector that returns the current draft transaction's gasTotal.
*
* @type {Selector<?string>}
*/
export function getGasTotal(state) {
return getCurrentDraftTransaction(state).gas?.gasTotal;
}
/**
* Selector that returns the error, if present, for the gas fields.
*
* @type {Selector<?string>}
*/
export function gasFeeIsInError(state) {
return Boolean(getCurrentDraftTransaction(state).gas?.error);
}
/**
* Selector that returns the minimum gasLimit for the current network.
*
* @type {Selector<string>}
*/
export function getMinimumGasLimitForSend(state) {
return state[name].gasLimitMinimum;
}
/**
* Selector that returns the current draft transaction's gasLimit.
*
* @type {Selector<MapValuesToUnion<SendStateGasModes>>}
*/
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<?Asset>}
*/
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<?string>}
*/
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<boolean>}
*/
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<?string>}
*/
export function getAssetError(state) {
return getSendAsset(state).error;
}
// Amount Selectors
/**
* Selector that returns the amount that current draft transaction is sending.
*
* @type {Selector<?string>}
*/
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<boolean>}
*/
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<boolean>}
*/
export function getSendMaxModeState(state) {
return state[name].amountMode === AMOUNT_MODES.MAX;
}
/**
* Selector that returns the current draft transaction's data field.
*
* @type {Selector<?string>}
*/
export function getSendHexData(state) {
return getCurrentDraftTransaction(state).userInputHexData;
}
/**
* Selector that returns the current draft transaction's id, if present.
*
* @type {Selector<?string>}
*/
export function getDraftTransactionID(state) {
return getCurrentDraftTransaction(state).id;
}
/**
* Selector that returns true if there is an error on the amount field.
*
* @type {Selector<boolean>}
*/
export function sendAmountIsInError(state) {
return Boolean(getCurrentDraftTransaction(state).amount?.error);
}
// Recipient Selectors
/**
* Selector that returns the current draft transaction's recipient.
*
* @type {Selector<DraftTransaction['recipient']>}
*/
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<?string>}
*/
export function getSendTo(state) {
return getRecipient(state)?.address;
}
/**
* Selector that returns true if the current recipientMode is MY_ACCOUNTS
*
* @type {Selector<boolean>}
*/
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<?string>}
*/
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<boolean>}
*/
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<boolean>}
*/
export function isSendFormInvalid(state) {
return getCurrentDraftTransaction(state).status === SEND_STATUSES.INVALID;
}
/**
* Selector that returns the current stage of the send flow
*
* @type {Selector<MapValuesToUnion<SendStateStages>>}
*/
export function getSendStage(state) {
return state[name].stage;
}