Skip to content

Commit 8b2ff03

Browse files
authored
feat: Implement speed row in gas-fee-estimates (#15880)
<!-- 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 aims to implement "speed" row in gas fee estimates. See recording - we change gas values and speed row is changing. Since these transactions are not reachable in the current user flow - adding no e2e test as it's not required. ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/5023 ## **Manual testing steps** 1. Go send flow while `transfer` redesigned transfer confirmations enabled 2. See speed row under network fee ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** - no speed row ![Screenshot 2025-05-27 at 12 03 35](https://github.com/user-attachments/assets/17f7e181-6b5e-42f9-aab6-e61e585dc908) ### **After** - with speed row https://github.com/user-attachments/assets/1ba8c407-81f5-465f-b35e-31cefa0f4ef3 ## **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.
1 parent b488a3c commit 8b2ff03

File tree

10 files changed

+403
-9
lines changed

10 files changed

+403
-9
lines changed
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export enum RowAlertKey {
2+
AccountTypeUpgrade = 'accountTypeUpgrade',
23
Blockaid = 'blockaid',
34
EstimatedFee = 'estimatedFee',
45
RequestFrom = 'requestFrom',
5-
AccountTypeUpgrade = 'accountTypeUpgrade',
6+
Speed = 'speed',
67
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
import React from 'react';
2+
import { merge } from 'lodash';
3+
import {
4+
UserFeeLevel,
5+
GasFeeEstimateLevel,
6+
GasFeeEstimateType,
7+
} from '@metamask/transaction-controller';
8+
import { GasFeeEstimates } from '@metamask/gas-fee-controller';
9+
10+
import renderWithProvider from '../../../../../../util/test/renderWithProvider';
11+
import { transferTransactionStateMock } from '../../../mock-data/transfer-transaction-mock';
12+
import { feeMarketEstimates } from '../../../mock-data/gas-fee-controller-mock';
13+
import { useGasFeeEstimates } from '../../../hooks/gas/useGasFeeEstimates';
14+
import { GasSpeed } from './gas-speed';
15+
16+
jest.mock('../../../hooks/gas/useGasFeeEstimates');
17+
18+
const mockUseGasFeeEstimates = useGasFeeEstimates as jest.MockedFunction<
19+
typeof useGasFeeEstimates
20+
>;
21+
22+
describe('GasSpeed', () => {
23+
beforeEach(() => {
24+
mockUseGasFeeEstimates.mockReturnValue({
25+
gasFeeEstimates: feeMarketEstimates as unknown as GasFeeEstimates,
26+
});
27+
});
28+
29+
afterEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('renders null when transaction metadata has no userFeeLevel', () => {
34+
const stateWithoutUserFeeLevel = merge({}, transferTransactionStateMock, {
35+
engine: {
36+
backgroundState: {
37+
TransactionController: {
38+
transactions: [
39+
{
40+
id: '56f60ff0-2bef-11f0-80ce-2f66f7fbd577',
41+
userFeeLevel: undefined,
42+
},
43+
],
44+
},
45+
},
46+
},
47+
});
48+
49+
const { queryByText } = renderWithProvider(<GasSpeed />, {
50+
state: stateWithoutUserFeeLevel,
51+
});
52+
53+
expect(queryByText(/🐢|🦊|🦍|🌐|/)).toBeNull();
54+
});
55+
56+
it.each([
57+
[GasFeeEstimateLevel.Low, 'Low', /🐢.*Low.*~ 30 sec/],
58+
[GasFeeEstimateLevel.Medium, 'Medium', /🦊.*Market.*~ 20 sec/],
59+
[GasFeeEstimateLevel.High, 'High', /🦍.*Aggressive.*~ 10 sec/],
60+
])(
61+
'renders correct content for %s gas fee estimate level',
62+
(userFeeLevel, _levelName, expectedPattern) => {
63+
const stateWithFeeLevel = merge({}, transferTransactionStateMock, {
64+
engine: {
65+
backgroundState: {
66+
TransactionController: {
67+
transactions: [
68+
{
69+
userFeeLevel,
70+
gasFeeEstimates: {
71+
type: GasFeeEstimateType.FeeMarket,
72+
},
73+
},
74+
],
75+
},
76+
},
77+
},
78+
});
79+
80+
const { getByText } = renderWithProvider(<GasSpeed />, {
81+
state: stateWithFeeLevel,
82+
});
83+
84+
expect(getByText(expectedPattern)).toBeTruthy();
85+
},
86+
);
87+
88+
it('renders correct content for DAPP_SUGGESTED user fee level', () => {
89+
const stateWithDappSuggested = merge({}, transferTransactionStateMock, {
90+
engine: {
91+
backgroundState: {
92+
TransactionController: {
93+
transactions: [
94+
{
95+
userFeeLevel: UserFeeLevel.DAPP_SUGGESTED,
96+
},
97+
],
98+
},
99+
},
100+
},
101+
});
102+
103+
const { getByText } = renderWithProvider(<GasSpeed />, {
104+
state: stateWithDappSuggested,
105+
});
106+
107+
expect(getByText('🌐 Site suggested')).toBeTruthy();
108+
});
109+
110+
it('renders correct content for CUSTOM user fee level', () => {
111+
const stateWithCustom = merge({}, transferTransactionStateMock, {
112+
engine: {
113+
backgroundState: {
114+
TransactionController: {
115+
transactions: [
116+
{
117+
userFeeLevel: UserFeeLevel.CUSTOM,
118+
},
119+
],
120+
},
121+
},
122+
},
123+
});
124+
125+
const { getByText } = renderWithProvider(<GasSpeed />, {
126+
state: stateWithCustom,
127+
});
128+
129+
expect(getByText('⚙️ Advanced')).toBeTruthy();
130+
});
131+
132+
it('does not show estimated time for gas price estimate when Medium level is selected', () => {
133+
const stateWithGasPriceEstimate = merge({}, transferTransactionStateMock, {
134+
engine: {
135+
backgroundState: {
136+
TransactionController: {
137+
transactions: [
138+
{
139+
userFeeLevel: GasFeeEstimateLevel.Medium,
140+
gasFeeEstimates: {
141+
type: GasFeeEstimateType.GasPrice,
142+
},
143+
},
144+
],
145+
},
146+
},
147+
},
148+
});
149+
150+
const { getByText, queryByText } = renderWithProvider(<GasSpeed />, {
151+
state: stateWithGasPriceEstimate,
152+
});
153+
154+
expect(getByText('🦊 Market')).toBeTruthy();
155+
expect(queryByText(/sec/)).toBeNull();
156+
});
157+
158+
it('shows estimated time for fee market estimates', () => {
159+
const stateWithFeeMarketEstimate = merge({}, transferTransactionStateMock, {
160+
engine: {
161+
backgroundState: {
162+
TransactionController: {
163+
transactions: [
164+
{
165+
userFeeLevel: GasFeeEstimateLevel.High,
166+
gasFeeEstimates: {
167+
type: GasFeeEstimateType.FeeMarket,
168+
},
169+
},
170+
],
171+
},
172+
},
173+
},
174+
});
175+
176+
const { getByText } = renderWithProvider(<GasSpeed />, {
177+
state: stateWithFeeMarketEstimate,
178+
});
179+
180+
expect(getByText(/🦍.*Aggressive.*~ 10 sec/)).toBeTruthy();
181+
});
182+
183+
it('handles unknown user fee level by defaulting to advanced', () => {
184+
const stateWithUnknownFeeLevel = merge({}, transferTransactionStateMock, {
185+
engine: {
186+
backgroundState: {
187+
TransactionController: {
188+
transactions: [
189+
{
190+
userFeeLevel: 'unknown_level' as unknown as UserFeeLevel,
191+
},
192+
],
193+
},
194+
},
195+
},
196+
});
197+
198+
const { getByText } = renderWithProvider(<GasSpeed />, {
199+
state: stateWithUnknownFeeLevel,
200+
});
201+
202+
expect(getByText('⚙️ Advanced')).toBeTruthy();
203+
});
204+
205+
it('calls useGasFeeEstimates with correct networkClientId', () => {
206+
const testNetworkClientId = 'test-network-client-id';
207+
const stateWithNetworkClientId = merge({}, transferTransactionStateMock, {
208+
engine: {
209+
backgroundState: {
210+
TransactionController: {
211+
transactions: [
212+
{
213+
networkClientId: testNetworkClientId,
214+
userFeeLevel: GasFeeEstimateLevel.Medium,
215+
},
216+
],
217+
},
218+
},
219+
},
220+
});
221+
222+
renderWithProvider(<GasSpeed />, {
223+
state: stateWithNetworkClientId,
224+
});
225+
226+
expect(mockUseGasFeeEstimates).toHaveBeenCalledWith(testNetworkClientId);
227+
});
228+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import React from 'react';
2+
import { type GasFeeEstimates } from '@metamask/gas-fee-controller';
3+
import {
4+
UserFeeLevel,
5+
GasFeeEstimateLevel,
6+
GasFeeEstimateType,
7+
} from '@metamask/transaction-controller';
8+
9+
import Text, {
10+
TextVariant,
11+
} from '../../../../../../component-library/components/Texts/Text';
12+
import { strings } from '../../../../../../../locales/i18n';
13+
import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest';
14+
import { GasOptionIcon } from '../../../constants/gas';
15+
import { useGasFeeEstimates } from '../../../hooks/gas/useGasFeeEstimates';
16+
import { toHumanSeconds } from '../../../utils/time';
17+
18+
const getEmoji = (userFeeLevel: UserFeeLevel | GasFeeEstimateLevel) => {
19+
switch (userFeeLevel) {
20+
case GasFeeEstimateLevel.Low:
21+
return GasOptionIcon.LOW;
22+
case GasFeeEstimateLevel.Medium:
23+
return GasOptionIcon.MEDIUM;
24+
case GasFeeEstimateLevel.High:
25+
return GasOptionIcon.HIGH;
26+
case UserFeeLevel.DAPP_SUGGESTED:
27+
return GasOptionIcon.SITE_SUGGESTED;
28+
case UserFeeLevel.CUSTOM:
29+
default:
30+
return GasOptionIcon.ADVANCED;
31+
}
32+
};
33+
34+
const getText = (userFeeLevel: UserFeeLevel | GasFeeEstimateLevel) => {
35+
switch (userFeeLevel) {
36+
case UserFeeLevel.DAPP_SUGGESTED:
37+
return strings('transactions.gas_modal.site_suggested');
38+
case UserFeeLevel.CUSTOM:
39+
return strings('transactions.gas_modal.advanced');
40+
case GasFeeEstimateLevel.Low:
41+
case GasFeeEstimateLevel.Medium:
42+
case GasFeeEstimateLevel.High:
43+
return strings(`transactions.gas_modal.${userFeeLevel}`);
44+
default:
45+
return strings('transactions.gas_modal.advanced');
46+
}
47+
};
48+
49+
const getEstimatedTime = (
50+
userFeeLevel: UserFeeLevel | GasFeeEstimateLevel,
51+
networkGasFeeEstimates: GasFeeEstimates,
52+
isGasPriceEstimateSelected: boolean,
53+
) => {
54+
const hasUnknownEstimatedTime =
55+
userFeeLevel === UserFeeLevel.DAPP_SUGGESTED ||
56+
userFeeLevel === UserFeeLevel.CUSTOM ||
57+
isGasPriceEstimateSelected ||
58+
!networkGasFeeEstimates?.[userFeeLevel];
59+
60+
if (hasUnknownEstimatedTime) {
61+
return '';
62+
}
63+
64+
const { minWaitTimeEstimate } = networkGasFeeEstimates[userFeeLevel];
65+
66+
const humanizedWaitTime = toHumanSeconds(minWaitTimeEstimate);
67+
68+
// Intentional space as prefix
69+
return ` ~ ${humanizedWaitTime}`;
70+
};
71+
72+
export const GasSpeed = () => {
73+
const transactionMeta = useTransactionMetadataRequest();
74+
const { gasFeeEstimates } = useGasFeeEstimates(
75+
transactionMeta?.networkClientId || '',
76+
);
77+
const networkGasFeeEstimates = gasFeeEstimates as GasFeeEstimates;
78+
79+
if (!transactionMeta?.userFeeLevel) {
80+
return null;
81+
}
82+
83+
const userFeeLevel = transactionMeta.userFeeLevel as
84+
| UserFeeLevel
85+
| GasFeeEstimateLevel;
86+
87+
const isGasPriceEstimateSelected =
88+
transactionMeta.gasFeeEstimates?.type === GasFeeEstimateType.GasPrice &&
89+
userFeeLevel === GasFeeEstimateLevel.Medium;
90+
91+
const emoji = getEmoji(userFeeLevel);
92+
const text = getText(userFeeLevel);
93+
const estimatedTime = getEstimatedTime(
94+
userFeeLevel,
95+
networkGasFeeEstimates,
96+
isGasPriceEstimateSelected,
97+
);
98+
99+
// Intentionally no space between text and estimated time
100+
return (
101+
<Text
102+
variant={TextVariant.BodySM}
103+
>{`${emoji} ${text}${estimatedTime}`}</Text>
104+
);
105+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { GasSpeed } from './gas-speed';

app/components/Views/confirmations/components/rows/transactions/gas-fee-details/gas-fee-details.test.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfir
99
import { TOOLTIP_TYPES } from '../../../../../../../core/Analytics/events/confirmations';
1010
import GasFeesDetails from './gas-fee-details';
1111

12+
jest.mock('../../../gas/gas-speed', () => ({
13+
GasSpeed: () => null,
14+
}));
1215
jest.mock('../../../../hooks/metrics/useConfirmationMetricEvents');
1316
jest.mock('../../../../../../../core/Engine', () => ({
1417
context: {
@@ -103,4 +106,11 @@ describe('GasFeesDetails', () => {
103106
}),
104107
);
105108
});
109+
110+
it('shows gas speed row', async () => {
111+
const { getByText } = renderWithProvider(<GasFeesDetails />, {
112+
state: stakingDepositConfirmationState,
113+
});
114+
expect(getByText('Speed')).toBeDefined();
115+
});
106116
});

app/components/Views/confirmations/components/rows/transactions/gas-fee-details/gas-fee-details.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import { useConfirmationMetricEvents } from '../../../../hooks/metrics/useConfir
1616
import { GasFeeModal } from '../../../modals/gas-fee-modal';
1717
import InfoSection from '../../../UI/info-row/info-section';
1818
import AlertRow from '../../../UI/info-row/alert-row';
19+
import InfoRow from '../../../UI/info-row';
1920
import { RowAlertKey } from '../../../UI/info-row/alert-row/constants';
21+
import { GasSpeed } from '../../../gas/gas-speed';
2022
import styleSheet from './gas-fee-details.styles';
2123

2224
const EstimationInfo = ({
@@ -79,6 +81,8 @@ const GasFeesDetails = ({ disableUpdate = false }) => {
7981
);
8082
const { trackTooltipClickedEvent } = useConfirmationMetricEvents();
8183

84+
const isUserFeeLevelExists = transactionMetadata?.userFeeLevel;
85+
8286
const handleNetworkFeeTooltipClickedEvent = () => {
8387
trackTooltipClickedEvent({
8488
tooltip: TOOLTIP_TYPES.NETWORK_FEE,
@@ -109,6 +113,11 @@ const GasFeesDetails = ({ disableUpdate = false }) => {
109113
)}
110114
</View>
111115
</AlertRow>
116+
{isUserFeeLevelExists && (
117+
<InfoRow label={strings('transactions.gas_modal.speed')}>
118+
<GasSpeed />
119+
</InfoRow>
120+
)}
112121
</InfoSection>
113122
{gasModalVisible && (
114123
<GasFeeModal setGasModalVisible={setGasModalVisible} />

0 commit comments

Comments
 (0)