Skip to content

Commit eec2e91

Browse files
authored
feat: update swap transaction depending on user selection (#37528)
<!-- 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** Allow user to select quoted swap. ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: ## **Related issues** Fixes: MetaMask/MetaMask-planning#6108 ## **Manual testing steps** 1. Submit a swap 2. Check swap selection option on confirmation page 3. Confirmation should update as user selects different swap options ## **Screenshots/Recordings** TODO ## **Pre-merge author checklist** - [X] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/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-extension/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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds swap selection on the confirmation screen to switch between dapp’s current swap and a MetaMask-quoted swap, updating txParams/batch via remote-flagged UI, with quote detection, metric fixes, tests, and minor styling. > > - **Confirmations UI**: > - `ui/pages/.../dapp-swap-comparison-banner.tsx`: Add "Current" vs "Save + Earn" selection; dispatch `updateTransaction` to swap between original dapp tx and selected quote (`txParams`, `batchTransactions`, `txParamsOriginal`); auto-select MetaMask when it’s a quoted swap; gate with remote flag `dappSwapUi.enabled/threshold`; allow test origin; minor style token update in `index.scss`. > - **Hooks**: > - New `useSwapCheck` to detect quoted swaps (diffs `txParamsOriginal` vs `txParams`). > - `useDappSwapComparisonInfo`: now returns `selectedQuote`; reads from `txParamsOriginal` when present; refines quote timing; no-op safety tweaks; metric capture ordering updates. > - `useDappSwapComparisonLatencyMetrics`: fix quote request latency calculation. > - **Gas UI**: > - `EditGasFeesRow`: hide edit-gas button when `isQuotedSwap` is true; tests added. > - **Tests**: > - Add/expand tests for banner interactions (button clicks dispatch), swap check, comparison info (selected quote and metrics). > - **Config**: > - Remove `DAPP_SWAP_SHIELD_ENABLED` from `builds.yml` and test env. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 933ebfb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2faaaff commit eec2e91

File tree

12 files changed

+415
-168
lines changed

12 files changed

+415
-168
lines changed

builds.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,6 +451,3 @@ env:
451451
# This should only be used for local testing, and should not be enabled in any
452452
# production builds (including beta and Flask).
453453
- FORCE_PREINSTALLED_SNAPS: 'false'
454-
455-
# Enable dapp swap shield implementation
456-
- DAPP_SWAP_SHIELD_ENABLED: false

test/env.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,3 @@ process.env.METAMASK_VERSION = 'MOCK_VERSION';
1818
process.env.TZ = 'UTC';
1919
process.env.SEEDLESS_ONBOARDING_ENABLED = 'true';
2020
process.env.METAMASK_SHIELD_ENABLED = 'false';
21-
process.env.DAPP_SWAP_SHIELD_ENABLED = 'true';

ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.test.tsx

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,29 @@
11
import React from 'react';
22
import configureMockStore from 'redux-mock-store';
3+
import { QuoteResponse } from '@metamask/bridge-controller';
4+
import { fireEvent } from '@testing-library/react';
35

46
import { getMockConfirmStateForTransaction } from '../../../../../../test/data/confirmations/helper';
57
import { mockSwapConfirmation } from '../../../../../../test/data/confirmations/contract-interaction';
68
import { renderWithConfirmContextProvider } from '../../../../../../test/lib/confirmations/render-helpers';
79
import { getRemoteFeatureFlags } from '../../../../../selectors/remote-feature-flags';
810
import { Confirmation } from '../../../types/confirm';
911
import { useDappSwapComparisonInfo } from '../../../hooks/transactions/dapp-swap-comparison/useDappSwapComparisonInfo';
12+
import * as SwapCheckHook from '../../../hooks/transactions/dapp-swap-comparison/useSwapCheck';
1013
import { DappSwapComparisonBanner } from './dapp-swap-comparison-banner';
1114

15+
const mockDispatch = jest.fn();
16+
jest.mock('react-redux', () => ({
17+
...jest.requireActual('react-redux'),
18+
useDispatch: () => mockDispatch,
19+
}));
20+
21+
const mockUpdateTransaction = jest.fn();
22+
jest.mock('../../../../../store/actions', () => ({
23+
...jest.requireActual('../../../../../store/actions'),
24+
updateTransaction: () => mockUpdateTransaction,
25+
}));
26+
1227
jest.mock(
1328
'../../../hooks/transactions/dapp-swap-comparison/useDappSwapComparisonInfo',
1429
() => ({
@@ -20,6 +35,43 @@ jest.mock(
2035

2136
jest.mock('../../../../../selectors/remote-feature-flags');
2237

38+
const quote = {
39+
quote: {
40+
aggregator: 'openocean',
41+
requestId:
42+
'0xf5fe1ea0c87b44825dfc89cc60c3398f1cf83eb49a07e491029e00cb72090ef2',
43+
bridgeId: 'okx',
44+
srcChainId: 42161,
45+
destChainId: 42161,
46+
srcTokenAmount: '9913',
47+
destTokenAmount: '1004000',
48+
minDestTokenAmount: '972870',
49+
walletAddress: '0x178239802520a9C99DCBD791f81326B70298d629',
50+
destWalletAddress: '0x178239802520a9C99DCBD791f81326B70298d629',
51+
bridges: ['okx'],
52+
protocols: ['okx'],
53+
steps: [],
54+
slippage: 2,
55+
},
56+
approval: {
57+
chainId: 42161,
58+
to: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831',
59+
from: '0x178239802520a9C99DCBD791f81326B70298d629',
60+
value: '0x0',
61+
data: '',
62+
gasLimit: 62000,
63+
},
64+
trade: {
65+
chainId: 42161,
66+
to: '0x9dDA6Ef3D919c9bC8885D5560999A3640431e8e6',
67+
from: '0x178239802520a9C99DCBD791f81326B70298d629',
68+
value: '0x0',
69+
data: '',
70+
gasLimit: 80000,
71+
},
72+
estimatedProcessingTimeInSeconds: 0,
73+
};
74+
2375
function render(args: Record<string, string> = {}) {
2476
const state = getMockConfirmStateForTransaction({
2577
...mockSwapConfirmation,
@@ -41,6 +93,7 @@ describe('<DappSwapComparisonBanner />', () => {
4193
beforeEach(() => {
4294
mockGetRemoteFeatureFlags.mockReturnValue({
4395
dappSwapMetrics: { enabled: true },
96+
dappSwapUi: { enabled: true, threshold: 0.01 },
4497
});
4598
});
4699

@@ -75,4 +128,35 @@ describe('<DappSwapComparisonBanner />', () => {
75128
const { container } = render();
76129
expect(container).toBeEmptyDOMElement();
77130
});
131+
132+
it('call function to update confirmation when user clicks on Save + Earn button', () => {
133+
mockUseDappSwapComparisonInfo.mockReturnValue({
134+
selectedQuote: quote as unknown as QuoteResponse,
135+
selectedQuoteValueDifference: 0.1,
136+
gasDifference: 0.01,
137+
tokenAmountDifference: 0.01,
138+
destinationTokenSymbol: 'TEST',
139+
} as ReturnType<typeof useDappSwapComparisonInfo>);
140+
const { getByText } = render();
141+
const quoteSwapButton = getByText('Save + Earn');
142+
fireEvent.click(quoteSwapButton);
143+
expect(mockDispatch).toHaveBeenCalledTimes(1);
144+
});
145+
146+
it('call function to update confirmation when user clicks on Current button', () => {
147+
jest.spyOn(SwapCheckHook, 'useSwapCheck').mockReturnValue({
148+
isQuotedSwap: true,
149+
});
150+
mockUseDappSwapComparisonInfo.mockReturnValue({
151+
selectedQuote: quote as unknown as QuoteResponse,
152+
selectedQuoteValueDifference: 0.1,
153+
gasDifference: 0.01,
154+
tokenAmountDifference: 0.01,
155+
destinationTokenSymbol: 'TEST',
156+
} as ReturnType<typeof useDappSwapComparisonInfo>);
157+
const { getByText } = render();
158+
const quoteSwapButton = getByText('Current');
159+
fireEvent.click(quoteSwapButton);
160+
expect(mockDispatch).toHaveBeenCalledTimes(1);
161+
});
78162
});

ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/dapp-swap-comparison-banner.tsx

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useCallback, useState } from 'react';
1+
import React, { useCallback, useEffect, useState } from 'react';
22
import {
33
Box,
44
BoxBackgroundColor,
@@ -14,17 +14,30 @@ import {
1414
TextColor,
1515
TextVariant,
1616
} from '@metamask/design-system-react';
17-
import { TransactionMeta } from '@metamask/transaction-controller';
18-
import { useSelector } from 'react-redux';
17+
import {
18+
BatchTransaction,
19+
TransactionMeta,
20+
} from '@metamask/transaction-controller';
21+
import { TxData } from '@metamask/bridge-controller';
22+
import { toHex } from '@metamask/controller-utils';
23+
import { useDispatch, useSelector } from 'react-redux';
1924

2025
import { getRemoteFeatureFlags } from '../../../../../selectors/remote-feature-flags';
2126
import { useI18nContext } from '../../../../../hooks/useI18nContext';
27+
import { updateTransaction } from '../../../../../store/actions';
2228
import { useConfirmContext } from '../../../context/confirm';
2329
import { useDappSwapComparisonInfo } from '../../../hooks/transactions/dapp-swap-comparison/useDappSwapComparisonInfo';
30+
import { useSwapCheck } from '../../../hooks/transactions/dapp-swap-comparison/useSwapCheck';
2431

2532
const DAPP_SWAP_COMPARISON_ORIGIN = 'https://app.uniswap.org';
33+
const TEST_DAPP_ORIGIN = 'https://metamask.github.io';
2634
const DAPP_SWAP_THRESHOLD = 0.01;
2735

36+
type DappSwapUiFlag = {
37+
enabled: boolean;
38+
threshold: number;
39+
};
40+
2841
const enum SwapType {
2942
Current = 'current',
3043
Metamask = 'metamask',
@@ -66,24 +79,90 @@ const SwapButton = ({
6679
const DappSwapComparisonInner = () => {
6780
const t = useI18nContext();
6881
const {
82+
selectedQuote,
6983
selectedQuoteValueDifference,
7084
gasDifference,
7185
tokenAmountDifference,
7286
destinationTokenSymbol,
7387
} = useDappSwapComparisonInfo();
88+
const { isQuotedSwap } = useSwapCheck();
89+
const dispatch = useDispatch();
90+
const { currentConfirmation } = useConfirmContext<TransactionMeta>();
91+
const { dappSwapUi } = useSelector(getRemoteFeatureFlags) as {
92+
dappSwapUi: DappSwapUiFlag;
93+
};
94+
95+
// update selectedSwapType depending on data
7496
const [selectedSwapType, setSelectedSwapType] = useState<SwapType>(
7597
SwapType.Current,
7698
);
7799
const [showDappSwapComparisonBanner, setShowDappSwapComparisonBanner] =
78100
useState<boolean>(true);
79101

102+
useEffect(() => {
103+
if (isQuotedSwap && selectedSwapType !== SwapType.Metamask) {
104+
setSelectedSwapType(SwapType.Metamask);
105+
}
106+
}, [isQuotedSwap, selectedSwapType]);
107+
80108
const hideDappSwapComparisonBanner = useCallback(() => {
81109
setShowDappSwapComparisonBanner(false);
82110
}, [setShowDappSwapComparisonBanner]);
83111

112+
const updateSwapToCurrent = useCallback(() => {
113+
setSelectedSwapType(SwapType.Current);
114+
setShowDappSwapComparisonBanner(true);
115+
if (currentConfirmation.txParamsOriginal) {
116+
dispatch(
117+
updateTransaction(
118+
{
119+
...currentConfirmation,
120+
txParams: currentConfirmation.txParamsOriginal,
121+
batchTransactions: undefined,
122+
},
123+
false,
124+
),
125+
);
126+
}
127+
}, [
128+
currentConfirmation,
129+
dispatch,
130+
setSelectedSwapType,
131+
setShowDappSwapComparisonBanner,
132+
]);
133+
134+
const updateSwapToSelectedQuote = useCallback(() => {
135+
setSelectedSwapType(SwapType.Metamask);
136+
setShowDappSwapComparisonBanner(true);
137+
const { value, gasLimit, data } = selectedQuote?.trade as TxData;
138+
dispatch(
139+
updateTransaction(
140+
{
141+
...currentConfirmation,
142+
txParams: {
143+
...currentConfirmation.txParams,
144+
value,
145+
gas: toHex(gasLimit ?? 0),
146+
data,
147+
},
148+
txParamsOriginal: currentConfirmation.txParams,
149+
batchTransactions: [selectedQuote?.approval as BatchTransaction],
150+
},
151+
false,
152+
),
153+
);
154+
}, [
155+
currentConfirmation,
156+
dispatch,
157+
setSelectedSwapType,
158+
setShowDappSwapComparisonBanner,
159+
selectedQuote,
160+
]);
161+
84162
if (
85-
process.env.DAPP_SWAP_SHIELD_ENABLED?.toString() !== 'true' ||
86-
selectedQuoteValueDifference < DAPP_SWAP_THRESHOLD
163+
!dappSwapUi?.enabled ||
164+
selectedQuoteValueDifference <
165+
(dappSwapUi?.threshold ?? DAPP_SWAP_THRESHOLD)
87166
) {
88167
return null;
89168
}
@@ -106,7 +185,7 @@ const DappSwapComparisonInner = () => {
106185
? SwapButtonType.ButtonType
107186
: SwapButtonType.Text
108187
}
109-
onClick={() => setSelectedSwapType(SwapType.Current)}
188+
onClick={updateSwapToCurrent}
110189
label={t('current')}
111190
/>
112191
<SwapButton
@@ -115,7 +194,7 @@ const DappSwapComparisonInner = () => {
115194
? SwapButtonType.ButtonType
116195
: SwapButtonType.Text
117196
}
118-
onClick={() => setSelectedSwapType(SwapType.Metamask)}
197+
onClick={updateSwapToSelectedQuote}
119198
label={t('saveAndEarn')}
120199
/>
121200
</Box>
@@ -178,7 +257,8 @@ export const DappSwapComparisonBanner = () => {
178257

179258
const dappSwapMetricsEnabled =
180259
(dappSwapMetrics as { enabled: boolean })?.enabled === true &&
181-
transactionMeta.origin === DAPP_SWAP_COMPARISON_ORIGIN;
260+
(transactionMeta.origin === DAPP_SWAP_COMPARISON_ORIGIN ||
261+
transactionMeta.origin === TEST_DAPP_ORIGIN);
182262

183263
if (!dappSwapMetricsEnabled) {
184264
return null;

ui/pages/confirmations/components/confirm/dapp-swap-comparison-banner/index.scss

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ Disabling Stylelint's hex color rule here because the TypeScript migration dashb
4040
}
4141

4242
&_text-save {
43-
color: #c9f570;
43+
color: var(--color-success-default-hover);
4444
margin-bottom: 4px;
4545
}
4646

ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/edit-gas-fees-row.test.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getMockConfirmStateForTransaction } from '../../../../../../../../test/
77
import { renderWithConfirmContextProvider } from '../../../../../../../../test/lib/confirmations/render-helpers';
88
import { GAS_FEE_TOKEN_MOCK } from '../../../../../../../../test/data/confirmations/gas';
99
import { genUnapprovedContractInteractionConfirmation } from '../../../../../../../../test/data/confirmations/contract-interaction';
10+
import * as SwapCheckHook from '../../../../../hooks/transactions/dapp-swap-comparison/useSwapCheck';
1011
import { EditGasFeesRow } from './edit-gas-fees-row';
1112

1213
jest.mock('../../../../simulation-details/useBalanceChanges', () => ({
@@ -62,4 +63,25 @@ describe('<EditGasFeesRow />', () => {
6263

6364
expect(getByTestId('gas-fee-token-fee')).toBeInTheDocument();
6465
});
66+
67+
it('renders edit gas fee button', () => {
68+
const { getByTestId } = render({
69+
gasFeeTokens: undefined,
70+
selectedGasFeeToken: undefined,
71+
});
72+
73+
expect(getByTestId('edit-gas-fee-icon')).toBeInTheDocument();
74+
});
75+
76+
it('does not renders edit gas fee button for quote suggested swap', () => {
77+
jest.spyOn(SwapCheckHook, 'useSwapCheck').mockReturnValue({
78+
isQuotedSwap: true,
79+
});
80+
const { queryByTestId } = render({
81+
gasFeeTokens: undefined,
82+
selectedGasFeeToken: undefined,
83+
});
84+
85+
expect(queryByTestId('edit-gas-fee-icon')).toBeNull();
86+
});
6587
});

ui/pages/confirmations/components/confirm/info/shared/edit-gas-fees-row/edit-gas-fees-row.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { getPreferences } from '../../../../../../../selectors';
2222
import { useConfirmContext } from '../../../../../context/confirm';
2323
import { useIsGaslessSupported } from '../../../../../hooks/gas/useIsGaslessSupported';
2424
import { selectConfirmationAdvancedDetailsOpen } from '../../../../../selectors/preferences';
25+
import { useSwapCheck } from '../../../../../hooks/transactions/dapp-swap-comparison/useSwapCheck';
2526
import { useBalanceChanges } from '../../../../simulation-details/useBalanceChanges';
2627
import { useSelectedGasFeeToken } from '../../hooks/useGasFeeToken';
2728
import { EditGasIconButton } from '../edit-gas-icon/edit-gas-icon-button';
@@ -58,6 +59,7 @@ export const EditGasFeesRow = ({
5859
const fiatValue = gasFeeToken ? gasFeeToken.amountFiat : fiatFee;
5960
const tokenValue = gasFeeToken ? gasFeeToken.amountFormatted : nativeFee;
6061
const metamaskFeeFiat = gasFeeToken?.metamaskFeeFiat;
62+
const { isQuotedSwap } = useSwapCheck();
6163

6264
const tooltip =
6365
gasFeeToken?.metaMaskFee && gasFeeToken.metaMaskFee !== '0x0'
@@ -101,7 +103,7 @@ export const EditGasFeesRow = ({
101103
{t('paidByMetaMask')}
102104
</Text>
103105
)}
104-
{!gasFeeToken && !isGasFeeSponsored && (
106+
{!isQuotedSwap && !gasFeeToken && !isGasFeeSponsored && (
105107
<EditGasIconButton
106108
supportsEIP1559={supportsEIP1559}
107109
setShowCustomizeGasPopover={setShowCustomizeGasPopover}

0 commit comments

Comments
 (0)