diff --git a/app/actions/transaction/index.js b/app/actions/transaction/index.js index 7cc0d95a3e0..0d327b73458 100644 --- a/app/actions/transaction/index.js +++ b/app/actions/transaction/index.js @@ -157,3 +157,17 @@ export function setProposedNonce(proposedNonce) { proposedNonce, }; } + +export function setMaxValueMode(maxValueMode) { + return { + type: 'SET_MAX_VALUE_MODE', + maxValueMode, + }; +} + +export function setTransactionValue(value) { + return { + type: 'SET_TRANSACTION_VALUE', + value, + }; +} diff --git a/app/components/Views/confirmations/SendFlow/Amount/index.js b/app/components/Views/confirmations/SendFlow/Amount/index.js index 10a38a98a6a..cf6319415fe 100644 --- a/app/components/Views/confirmations/SendFlow/Amount/index.js +++ b/app/components/Views/confirmations/SendFlow/Amount/index.js @@ -17,6 +17,7 @@ import { prepareTransaction, setTransactionObject, resetTransaction, + setMaxValueMode, } from '../../../../../actions/transaction'; import { getSendFlowTitle } from '../../../../UI/Navbar'; import StyledButton from '../../../../UI/StyledButton'; @@ -485,6 +486,10 @@ class Amount extends PureComponent { * Type of gas fee estimate provided by the gas fee controller. */ gasEstimateType: PropTypes.string, + /** + * Function that sets the max value mode + */ + setMaxValueMode: PropTypes.func, }; state = { @@ -926,9 +931,17 @@ class Amount extends PureComponent { }; onInputChange = (inputValue, selectedAsset, useMax) => { - const { contractExchangeRates, conversionRate, currentCurrency, ticker } = - this.props; + const { + contractExchangeRates, + conversionRate, + currentCurrency, + ticker, + setMaxValueMode, + } = this.props; const { internalPrimaryCurrencyIsCrypto } = this.state; + + setMaxValueMode(useMax ?? false) + let inputValueConversion, renderableInputValueConversion, hasExchangeRate, @@ -1567,6 +1580,7 @@ const mapDispatchToProps = (dispatch) => ({ setSelectedAsset: (selectedAsset) => dispatch(setSelectedAsset(selectedAsset)), resetTransaction: () => dispatch(resetTransaction()), + setMaxValueMode: (maxValueMode) => dispatch(setMaxValueMode(maxValueMode)), }); export default connect( diff --git a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx index 4f57005c85d..05d8097eea6 100644 --- a/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx +++ b/app/components/Views/confirmations/SendFlow/Amount/index.test.tsx @@ -9,6 +9,7 @@ import TransactionTypes from '../../../../../core/TransactionTypes'; import { AmountViewSelectorsIDs } from '../../../../../../e2e/selectors/SendFlow/AmountView.selectors'; import { backgroundState } from '../../../../../util/test/initial-root-state'; +import { setMaxValueMode } from '../../../../../actions/transaction'; const mockTransactionTypes = TransactionTypes; @@ -67,6 +68,13 @@ jest.mock('../../../../../util/transaction-controller', () => ({ ), })); +jest.mock('../../../../../actions/transaction', () => ({ + ...jest.requireActual('../../../../../actions/transaction'), + setMaxValueMode: jest.fn().mockReturnValue({ + type: 'SET_MAX_VALUE_MODE', + }), +})); + const mockNavigate = jest.fn(); const CURRENT_ACCOUNT = '0x76cf1CdD1fcC252442b50D6e97207228aA4aefC3'; @@ -236,6 +244,15 @@ describe('Amount', () => { expect(toJSON()).toMatchSnapshot(); }); + it('should set max value mode when toggled on', () => { + const { getByText } = renderComponent(initialState); + + const useMaxButton = getByText(/Use max/); + fireEvent.press(useMaxButton); + + expect(setMaxValueMode).toHaveBeenCalled(); + }); + it('should proceed if balance is sufficient while on Native primary currency', async () => { const { getByText, getByTestId, toJSON } = renderComponent({ engine: { diff --git a/app/components/Views/confirmations/SendFlow/Confirm/index.js b/app/components/Views/confirmations/SendFlow/Confirm/index.js index 7aa29a398e1..6246ec3a062 100644 --- a/app/components/Views/confirmations/SendFlow/Confirm/index.js +++ b/app/components/Views/confirmations/SendFlow/Confirm/index.js @@ -36,6 +36,7 @@ import { setNonce, setProposedNonce, setTransactionId, + setTransactionValue, } from '../../../../../actions/transaction'; import { getGasLimit } from '../../../../../util/custom-gas'; import Engine from '../../../../../core/Engine'; @@ -135,6 +136,7 @@ import { validateSufficientBalance, } from './validation'; import { buildTransactionParams } from '../../../../../util/confirmation/transactions'; +import { updateTransactionToMaxValue } from './utils'; const EDIT = 'edit'; const EDIT_NONCE = 'edit_nonce'; @@ -284,6 +286,14 @@ class Confirm extends PureComponent { * Object containing blockaid validation response for confirmation */ securityAlertResponse: PropTypes.object, + /** + * Boolean that indicates if the max value mode is enabled + */ + maxValueMode: PropTypes.bool, + /** + * Function that sets the transaction value + */ + setTransactionValue: PropTypes.func, }; state = { @@ -318,7 +328,8 @@ class Confirm extends PureComponent { ); setNetworkNonce = async () => { - const { networkClientId, setNonce, setProposedNonce, transaction } = this.props; + const { networkClientId, setNonce, setProposedNonce, transaction } = + this.props; const proposedNonce = await getNetworkNonce(transaction, networkClientId); setNonce(proposedNonce); setProposedNonce(proposedNonce); @@ -349,8 +360,8 @@ class Confirm extends PureComponent { request_source: this.originIsMMSDKRemoteConn ? AppConstants.REQUEST_SOURCES.SDK_REMOTE_CONN : this.originIsWalletConnect - ? AppConstants.REQUEST_SOURCES.WC - : AppConstants.REQUEST_SOURCES.IN_APP_BROWSER, + ? AppConstants.REQUEST_SOURCES.WC + : AppConstants.REQUEST_SOURCES.IN_APP_BROWSER, is_smart_transaction: shouldUseSmartTransaction || false, }; @@ -544,13 +555,20 @@ class Confirm extends PureComponent { componentDidUpdate = (prevProps, prevState) => { const { + accounts, transactionState: { transactionTo, - transaction: { value, gas }, + transaction: { value, gas, from }, }, contractBalances, selectedAsset, + maxValueMode, + gasFeeEstimates, } = this.props; + + const { transactionMeta } = this.state; + const { id: transactionId } = transactionMeta; + this.updateNavBar(); const transaction = this.prepareTransactionToSend(); @@ -573,6 +591,13 @@ class Confirm extends PureComponent { const haveEIP1559TotalMaxHexChanged = EIP1559GasTransaction.totalMaxHex !== prevState.EIP1559GasTransaction.totalMaxHex; + const isEIP1559Transaction = + this.props.gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET; + const haveGasFeeMaxNativeChanged = isEIP1559Transaction + ? EIP1559GasTransaction.gasFeeMaxNative !== + prevState.EIP1559GasTransaction.gasFeeMaxNative + : legacyGasTransaction.gasFeeMaxNative !== + prevState.legacyGasTransaction.gasFeeMaxNative; const haveGasPropertiesChanged = (this.props.gasFeeEstimates && @@ -602,6 +627,26 @@ class Confirm extends PureComponent { ? AppConstants.GAS_OPTIONS.MEDIUM : this.state.gasSelected; + if ( + maxValueMode && + selectedAsset.isETH && + !isEmpty(gasFeeEstimates) && + haveGasFeeMaxNativeChanged + ) { + updateTransactionToMaxValue({ + transactionId, + isEIP1559Transaction, + EIP1559GasTransaction, + legacyGasTransaction, + accountBalance: accounts[from].balance, + setTransactionValue: this.props.setTransactionValue, + }); + + // In order to prevent race condition do not remove this early return. + // Another update will be triggered by `updateEditableParams` and validateAmount will be called next update. + return; + } + if ( (!this.state.stopUpdateGas && !this.state.advancedGasInserted) || gasEstimateTypeChanged @@ -609,7 +654,6 @@ class Confirm extends PureComponent { if (this.props.gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { error = this.validateAmount({ transaction, - total: EIP1559GasTransaction.totalMaxHex, }); this.setError(error); // eslint-disable-next-line react/no-did-update-set-state @@ -639,7 +683,6 @@ class Confirm extends PureComponent { } else { error = this.validateAmount({ transaction, - total: legacyGasTransaction.totalHex, }); this.setError(error); } @@ -803,7 +846,7 @@ class Confirm extends PureComponent { * Validates transaction balances * @returns - Whether there is an error with the amount */ - validateAmount = ({ transaction, total }) => { + validateAmount = ({ transaction }) => { const { accounts, contractBalances, @@ -819,7 +862,7 @@ class Confirm extends PureComponent { const selectedAddress = transaction?.from; const weiBalance = hexToBN(accounts[selectedAddress].balance); - const totalTransactionValue = hexToBN(total); + const totalTransactionValue = hexToBN(value); if (!isDecimal(value)) { return strings('transaction.invalid_amount'); @@ -911,7 +954,6 @@ class Confirm extends PureComponent { transactionState: { assetType }, navigation, resetTransaction, - gasEstimateType, shouldUseSmartTransaction, transactionMetadata, } = this.props; @@ -919,12 +961,7 @@ class Confirm extends PureComponent { const transactionSimulationData = transactionMetadata?.simulationData; const { isUpdatedAfterSecurityCheck } = transactionSimulationData ?? {}; - const { - legacyGasTransaction, - transactionConfirmed, - EIP1559GasTransaction, - isChangeInSimulationModalShown, - } = this.state; + const { transactionConfirmed, isChangeInSimulationModalShown } = this.state; if (transactionConfirmed) return; if (isUpdatedAfterSecurityCheck && !isChangeInSimulationModalShown) { @@ -948,18 +985,9 @@ class Confirm extends PureComponent { try { const transaction = this.prepareTransactionToSend(); - let error; - if (gasEstimateType === GAS_ESTIMATE_TYPES.FEE_MARKET) { - error = this.validateAmount({ - transaction, - total: EIP1559GasTransaction.totalMaxHex, - }); - } else { - error = this.validateAmount({ - transaction, - total: legacyGasTransaction.totalHex, - }); - } + const error = this.validateAmount({ + transaction, + }); this.setError(error); if (error) { this.setState({ transactionConfirmed: false, stopUpdateGas: true }); @@ -1245,15 +1273,15 @@ class Confirm extends PureComponent { closeModal: true, ...(txnType ? { - legacyGasTransaction: gasTxn, - legacyGasObject: gasObj, - advancedGasInserted: !gasSelect, - stopUpdateGas: false, - } + legacyGasTransaction: gasTxn, + legacyGasObject: gasObj, + advancedGasInserted: !gasSelect, + stopUpdateGas: false, + } : { - EIP1559GasTransaction: gasTxn, - EIP1559GasObject: gasObj, - }), + EIP1559GasTransaction: gasTxn, + EIP1559GasObject: gasObj, + }), }); }; @@ -1576,10 +1604,10 @@ const mapStateToProps = (state) => ({ ), shouldUseSmartTransaction: selectShouldUseSmartTransaction(state), transactionMetricsById: selectTransactionMetrics(state), - transactionMetadata: - selectCurrentTransactionMetadata(state), + transactionMetadata: selectCurrentTransactionMetadata(state), useTransactionSimulations: selectUseTransactionSimulations(state), securityAlertResponse: selectCurrentTransactionSecurityAlertResponse(state), + maxValueMode: state.transaction.maxValueMode, }); const mapDispatchToProps = (dispatch) => ({ @@ -1595,6 +1623,7 @@ const mapDispatchToProps = (dispatch) => ({ showAlert: (config) => dispatch(showAlert(config)), updateTransactionMetrics: ({ transactionId, params }) => dispatch(updateTransactionMetrics({ transactionId, params })), + setTransactionValue: (value) => dispatch(setTransactionValue(value)), }); export default connect( diff --git a/app/components/Views/confirmations/SendFlow/Confirm/utils.test.ts b/app/components/Views/confirmations/SendFlow/Confirm/utils.test.ts new file mode 100644 index 00000000000..2ffef529897 --- /dev/null +++ b/app/components/Views/confirmations/SendFlow/Confirm/utils.test.ts @@ -0,0 +1,43 @@ +import { updateTransactionToMaxValue } from './utils'; +import { BN } from 'ethereumjs-util'; +import { toWei } from '../../../../../util/number'; + +// Mock the Engine and its context +jest.mock('../../../../../util/transaction-controller', () => ({ + updateEditableParams: jest.fn().mockResolvedValue({ + txParams: { value: '0x0' }, + }), +})); + +describe('updateTransactionToMaxValue', () => { + it('should update the transaction value correctly', async () => { + const transactionId = 'testTransactionId'; + const isEIP1559Transaction = true; + const EIP1559GasTransaction = { gasFeeMaxNative: '0.01' }; + const legacyGasTransaction = { gasFeeMaxNative: '0.02' }; + const accountBalance = '0x2386f26fc10000'; // 0.1 ether in wei + const setTransactionValue = jest.fn(); + + await updateTransactionToMaxValue({ + transactionId, + isEIP1559Transaction, + EIP1559GasTransaction, + legacyGasTransaction, + accountBalance, + setTransactionValue, + }); + + // Calculate expected max transaction value + const accountBalanceBN = new BN('2386f26fc10000', 16); // 0.1 ether in wei + const transactionFeeMax = new BN(toWei('0.01', 'ether'), 10); + const expectedMaxTransactionValueBN = + accountBalanceBN.sub(transactionFeeMax); + const expectedMaxTransactionValueHex = + '0x' + expectedMaxTransactionValueBN.toString(16); + + // Check if setTransactionValue was called with the correct value + expect(setTransactionValue).toHaveBeenCalledWith( + expectedMaxTransactionValueHex, + ); + }); +}); diff --git a/app/components/Views/confirmations/SendFlow/Confirm/utils.ts b/app/components/Views/confirmations/SendFlow/Confirm/utils.ts new file mode 100644 index 00000000000..965b4525bd5 --- /dev/null +++ b/app/components/Views/confirmations/SendFlow/Confirm/utils.ts @@ -0,0 +1,42 @@ +import { BN } from 'ethereumjs-util'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { remove0x, add0x } from '@metamask/utils'; +import { toWei } from '../../../../../util/number'; +import { updateEditableParams } from '../../../../../util/transaction-controller'; + +export const updateTransactionToMaxValue = async ({ + transactionId, + isEIP1559Transaction, + EIP1559GasTransaction, + legacyGasTransaction, + accountBalance, + setTransactionValue, +}: { + transactionId: string; + isEIP1559Transaction: boolean; + EIP1559GasTransaction: { + gasFeeMaxNative: string; + }; + legacyGasTransaction: { + gasFeeMaxNative: string; + }; + accountBalance: string; + setTransactionValue: (value: string) => void; +}) => { + const { gasFeeMaxNative } = isEIP1559Transaction + ? EIP1559GasTransaction + : legacyGasTransaction; + + const accountBalanceBN = new BN(remove0x(accountBalance), 16); + const transactionFeeMax = new BN(toWei(gasFeeMaxNative, 'ether'), 16); + + const maxTransactionValueBN = accountBalanceBN.sub(transactionFeeMax); + + const maxTransactionValueHex = add0x(maxTransactionValueBN.toString(16)); + + const txMeta = (await updateEditableParams(transactionId, { + value: maxTransactionValueHex, + })) as TransactionMeta; + + setTransactionValue(txMeta.txParams.value as string); +}; diff --git a/app/components/Views/confirmations/components/TransactionReview/TransactionReviewEIP1559Update/index.tsx b/app/components/Views/confirmations/components/TransactionReview/TransactionReviewEIP1559Update/index.tsx index e0986b3c59b..c927fdee7a6 100644 --- a/app/components/Views/confirmations/components/TransactionReview/TransactionReviewEIP1559Update/index.tsx +++ b/app/components/Views/confirmations/components/TransactionReview/TransactionReviewEIP1559Update/index.tsx @@ -71,6 +71,7 @@ const TransactionReviewEIP1559Update = ({ }); const { + gasFeeMaxNative, renderableGasFeeMinNative, renderableGasFeeMinConversion, renderableGasFeeMaxNative, @@ -93,7 +94,12 @@ const TransactionReviewEIP1559Update = ({ updateTransactionState(gasTransaction); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [gasEstimationReady, updateTransactionState, suggestedGasLimit]); + }, [ + gasEstimationReady, + updateTransactionState, + suggestedGasLimit, + gasFeeMaxNative, + ]); const openLinkAboutGas = useCallback( () => Linking.openURL(AppConstants.URLS.WHY_TRANSACTION_TAKE_TIME), diff --git a/app/reducers/transaction/index.js b/app/reducers/transaction/index.js index d3c4ed135fe..5d1ab2efb94 100644 --- a/app/reducers/transaction/index.js +++ b/app/reducers/transaction/index.js @@ -29,6 +29,7 @@ const initialState = { proposedNonce: undefined, nonce: undefined, securityAlertResponses: {}, + useMax: false, }; const getAssetType = (selectedAsset) => { @@ -146,6 +147,18 @@ const transactionReducer = (state = initialState, action) => { id: transactionId, }; } + case 'SET_MAX_VALUE_MODE': { + return { + ...state, + maxValueMode: action.maxValueMode, + }; + } + case 'SET_TRANSACTION_VALUE': { + return { + ...state, + transaction: { ...state.transaction, value: action.value }, + }; + } default: return state; } diff --git a/app/util/transaction-controller/index.test.ts b/app/util/transaction-controller/index.test.ts index 739678d5253..f3990dc5095 100644 --- a/app/util/transaction-controller/index.test.ts +++ b/app/util/transaction-controller/index.test.ts @@ -37,6 +37,7 @@ jest.mock('../../core/Engine', () => ({ updateSecurityAlertResponse: jest.fn(), updateTransaction: jest.fn(), wipeTransactions: jest.fn(), + updateEditableParams: jest.fn(), }, }, })); diff --git a/app/util/transaction-controller/index.ts b/app/util/transaction-controller/index.ts index fecf09e428b..9700fbb497c 100644 --- a/app/util/transaction-controller/index.ts +++ b/app/util/transaction-controller/index.ts @@ -108,6 +108,13 @@ export function wipeTransactions( return TransactionController.wipeTransactions(...args); } +export function updateEditableParams( + ...args: Parameters +) { + const { TransactionController } = Engine.context; + return TransactionController.updateEditableParams(...args); +} + export const getNetworkNonce = async ( { from }: { from: string }, networkClientId: NetworkClientId,