Skip to content

Commit

Permalink
feat: Implement send max to send flow (#12754)
Browse files Browse the repository at this point in the history
<!--
Please submit this PR as a draft initially.
Do not mark it as "Ready for review" until the template has been
completely filled out, and PR status checks have passed at least once.
-->

## **Description**

<!--
Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change?
2. What is the improvement/solution?
-->

This PR implements "Use Max" feature to the send flow. 

In the `Amount` step we set `maxValueMode` as `true`. When user gets in
to the `Confirm` step, depending on the value, transaction value is
getting updated.

## **Related issues**

#8516 

## **Manual testing steps**

1. Try send eth - initiate from wallet
2. "Use max" value on the `Amount` screen
3. See that amount is adjusted on every gas update

## **Screenshots/Recordings**



https://github.com/user-attachments/assets/b785887e-1cb1-4937-9b3d-c15cc72057ea



### **Before**

<!-- [screenshots/recordings] -->

### **After**

<!-- [screenshots/recordings] -->

## **Pre-merge author checklist**

- [X] I’ve followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [X] I've completed the PR template to the best of my ability
- [X] I’ve included tests if applicable
- [X] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [X] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
OGPoyraz authored Jan 9, 2025
1 parent 8cc7e0f commit 2c5a2d0
Show file tree
Hide file tree
Showing 10 changed files with 226 additions and 40 deletions.
14 changes: 14 additions & 0 deletions app/actions/transaction/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
18 changes: 16 additions & 2 deletions app/components/Views/confirmations/SendFlow/Amount/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
prepareTransaction,
setTransactionObject,
resetTransaction,
setMaxValueMode,
} from '../../../../../actions/transaction';
import { getSendFlowTitle } from '../../../../UI/Navbar';
import StyledButton from '../../../../UI/StyledButton';
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)

Check warning on line 943 in app/components/Views/confirmations/SendFlow/Amount/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Missing semicolon

Check warning on line 943 in app/components/Views/confirmations/SendFlow/Amount/index.js

View workflow job for this annotation

GitHub Actions / scripts (lint)

Missing semicolon

let inputValueConversion,
renderableInputValueConversion,
hasExchangeRate,
Expand Down Expand Up @@ -1567,6 +1580,7 @@ const mapDispatchToProps = (dispatch) => ({
setSelectedAsset: (selectedAsset) =>
dispatch(setSelectedAsset(selectedAsset)),
resetTransaction: () => dispatch(resetTransaction()),
setMaxValueMode: (maxValueMode) => dispatch(setMaxValueMode(maxValueMode)),
});

export default connect(
Expand Down
17 changes: 17 additions & 0 deletions app/components/Views/confirmations/SendFlow/Amount/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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: {
Expand Down
103 changes: 66 additions & 37 deletions app/components/Views/confirmations/SendFlow/Confirm/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
setNonce,
setProposedNonce,
setTransactionId,
setTransactionValue,
} from '../../../../../actions/transaction';
import { getGasLimit } from '../../../../../util/custom-gas';
import Engine from '../../../../../core/Engine';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};

Expand Down Expand Up @@ -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();
Expand All @@ -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 &&
Expand Down Expand Up @@ -602,14 +627,33 @@ 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
) {
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
Expand Down Expand Up @@ -639,7 +683,6 @@ class Confirm extends PureComponent {
} else {
error = this.validateAmount({
transaction,
total: legacyGasTransaction.totalHex,
});
this.setError(error);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -911,20 +954,14 @@ class Confirm extends PureComponent {
transactionState: { assetType },
navigation,
resetTransaction,
gasEstimateType,
shouldUseSmartTransaction,
transactionMetadata,
} = this.props;

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) {
Expand All @@ -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 });
Expand Down Expand Up @@ -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,
}),
});
};

Expand Down Expand Up @@ -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) => ({
Expand All @@ -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(
Expand Down
43 changes: 43 additions & 0 deletions app/components/Views/confirmations/SendFlow/Confirm/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
});
Loading

0 comments on commit 2c5a2d0

Please sign in to comment.