Skip to content

Commit 1f8d01a

Browse files
authored
feat: adds deeply liquid stablecoin slippage value of 0.5 (#31744)
<!-- 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** ### Purpose: To automatically apply a lower default slippage (0.5%) when fetching swap quotes for stablecoin pairs (like USDC/USDT), while using the user's custom setting for other pairs. This improves the swap experience for stablecoins and avoids unnecessary API calls with incorrect slippage. ### Changes Made: - Address-Based Stablecoin Definition: Introduced a StablecoinsByChainId map in shared/constants/swaps.ts that lists known stablecoin contract addresses for each supported network, mirroring the mobile implementation. - Refactored Slippage Logic: Modified the fetchQuotesAndSetQuoteState function in ui/ducks/swaps/swaps.js to: --- Check the from and to token addresses against the StablecoinsByChainId map for the current network using a case-insensitive comparison. --- Determine the correct slippage (Slippage.stable or the user's maxSlippage) before dispatching the quote fetch action. - Updated Fetch & Analytics: Ensured the determined slippage value is passed to the API request and correctly logged in analytics events. <!-- 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? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/31744?quickstart=1) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMS-2169 ## **Manual testing steps** 1. Go to the swap page 2. On any EVM chain, try to swap between USDT <-> USDC 3. Observe a default slippage value of 0.5% instead of 2% ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <img width="398" alt="Screenshot 2025-04-08 at 11 06 50 PM" src="https://github.com/user-attachments/assets/e217a785-fcea-43b4-9c7f-1917ad39dbf4" /> <img width="396" alt="Screenshot 2025-04-08 at 11 06 45 PM" src="https://github.com/user-attachments/assets/e501d8b4-d9e9-4d04-8a86-d60865ef140b" /> <img width="395" alt="Screenshot 2025-04-08 at 11 06 59 PM" src="https://github.com/user-attachments/assets/6df22839-9a03-40b4-8fdc-b427d3a90f25" /> <!-- [screenshots/recordings] --> ### **After** <img width="397" alt="Screenshot 2025-04-08 at 11 07 26 PM" src="https://github.com/user-attachments/assets/7dcb5a60-822d-4ce6-94ea-fc1678f889e7" /> <img width="396" alt="Screenshot 2025-04-08 at 11 07 31 PM" src="https://github.com/user-attachments/assets/7cf67f69-5278-4414-a96d-457f24fb6c45" /> <img width="397" alt="Screenshot 2025-04-08 at 11 07 42 PM" src="https://github.com/user-attachments/assets/8f7c4c90-63fb-467e-a3ab-3e91db6882e0" /> <!-- [screenshots/recordings] --> ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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 3ab2865 commit 1f8d01a

File tree

4 files changed

+129
-10
lines changed

4 files changed

+129
-10
lines changed

shared/constants/swaps.ts

+62
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,7 @@ export enum TokenBucketPriority {
322322
export enum Slippage {
323323
default = 2,
324324
high = 3,
325+
stable = 0.5,
325326
}
326327

327328
const ETH_USDC_TOKEN_OBJECT = {
@@ -435,3 +436,64 @@ export const SWAPS_CHAINID_COMMON_TOKEN_PAIR = {
435436
[MultichainNetworks.SOLANA]: SOLANA_USDC_TOKEN_OBJECT,
436437
///: END:ONLY_INCLUDE_IF
437438
};
439+
440+
export const STABLE_PAIRS: Record<string, boolean> = {
441+
[CURRENCY_SYMBOLS.USDC]: true,
442+
[CURRENCY_SYMBOLS.USDT]: true,
443+
};
444+
445+
export function isStablePair(
446+
sourceSymbol: string,
447+
destinationSymbol: string,
448+
): boolean {
449+
return STABLE_PAIRS[sourceSymbol] && STABLE_PAIRS[destinationSymbol];
450+
}
451+
452+
/**
453+
* A map of chain IDs to sets of known stablecoin contract addresses with deep liquidity.
454+
* Used to determine if a pair qualifies for lower default slippage to avoid frontrunning.
455+
* Just using USDC and USDT for now, but can add more as needed.
456+
*/
457+
export const StablecoinsByChainId: Partial<Record<string, Set<string>>> = {
458+
[CHAIN_IDS.MAINNET]: new Set([
459+
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
460+
'0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
461+
]),
462+
[CHAIN_IDS.LINEA_MAINNET]: new Set([
463+
'0x176211869cA2b568f2A7D4EE941E073a821EE1ff', // USDC
464+
'0xA219439258ca9da29E9Cc4cE5596924745e12B93', // USDT
465+
]),
466+
[CHAIN_IDS.POLYGON]: new Set([
467+
'0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359', // USDC
468+
'0x2791bca1f2de4661ed88a30c99a7a9449aa84174', // USDC.e
469+
'0xc2132d05d31c914a87c6611c10748aeb04b58e8f', // USDT
470+
]),
471+
[CHAIN_IDS.ARBITRUM]: new Set([
472+
'0xaf88d065e77c8cC2239327C5EDb3A432268e5831', // USDC
473+
'0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8', // USDC.e
474+
'0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', // USDT
475+
]),
476+
[CHAIN_IDS.BASE]: new Set([
477+
'0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', // USDC
478+
]),
479+
[CHAIN_IDS.OPTIMISM]: new Set([
480+
'0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85', // USDC
481+
'0x7F5c764cBc14f9669B88837ca1490cCa17c31607', // USDC.e
482+
'0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', // USDT
483+
]),
484+
[CHAIN_IDS.BSC]: new Set([
485+
'0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d', // USDC
486+
'0x55d398326f99059ff775485246999027b3197955', // USDT
487+
]),
488+
[CHAIN_IDS.AVALANCHE]: new Set([
489+
'0xb97ef9ef8734c71904d8002f8b6bc66dd9c48a6e', // USDC
490+
'0xa7d7079b0fead91f3e65f86e8915cb59c1a4c664', // USDC.e
491+
'0x9702230a8ea53601f5cd2dc00fdbc13d4df4a8c7', // USDT
492+
'0xc7198437980c041c805a1edcba50c1ce5db95118', // USDT.e
493+
]),
494+
[CHAIN_IDS.ZKSYNC_ERA]: new Set([
495+
'0x1d17CBcF0D6D143135aE902365D2E5e2A16538D4', // USDC
496+
'0x3355df6D4c9C3035724Fd0e3914dE96A5a83aaf4', // USDC.e
497+
'0x493257fD37EDB34451f62EDf8D2a0C418852bA4C', // USDT
498+
]),
499+
};

ui/ducks/swaps/swaps.js

+45-8
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ import {
9090
SWAPS_FETCH_ORDER_CONFLICT,
9191
ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS,
9292
Slippage,
93+
StablecoinsByChainId,
9394
SWAPS_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE,
9495
} from '../../../shared/constants/swaps';
9596
import {
@@ -638,13 +639,14 @@ export const fetchQuotesAndSetQuoteState = (
638639
const state = getState();
639640
const hdEntropyIndex = getHDEntropyIndex(state);
640641
const selectedNetwork = getSelectedNetwork(state);
642+
const { chainId } = selectedNetwork.configuration;
641643
let swapsLivenessForNetwork = {
642644
swapsFeatureIsLive: false,
643645
};
644646
try {
645647
const swapsFeatureFlags = await fetchSwapsFeatureFlags();
646648
swapsLivenessForNetwork = getSwapsLivenessForNetwork(
647-
selectedNetwork.configuration.chainId,
649+
chainId,
648650
swapsFeatureFlags,
649651
);
650652
} catch (error) {
@@ -758,6 +760,35 @@ export const fetchQuotesAndSetQuoteState = (
758760
const smartTransactionsEnabled = getSmartTransactionsEnabled(state);
759761
const currentSmartTransactionsEnabled =
760762
getCurrentSmartTransactionsEnabled(state);
763+
764+
// Helper function for case-insensitive address check in a Set
765+
const checkAddressInSetCaseInsensitive = (addressSet, addressToCheck) => {
766+
if (!addressToCheck) {
767+
return false;
768+
}
769+
const lowerAddressToCheck = addressToCheck.toLowerCase();
770+
for (const addrInSet of addressSet) {
771+
if (addrInSet.toLowerCase() === lowerAddressToCheck) {
772+
return true;
773+
}
774+
}
775+
return false;
776+
};
777+
778+
// Determines if the pair is an eligible stable token pair using case-insensitive check.
779+
// If the pair is a stablecoin pair in our list, we can use a lower slippage value of 0.5%.
780+
const stablecoinsForChain = StablecoinsByChainId[chainId];
781+
const isStableTokenPair = Boolean(
782+
stablecoinsForChain &&
783+
checkAddressInSetCaseInsensitive(
784+
stablecoinsForChain,
785+
fromTokenAddress,
786+
) &&
787+
checkAddressInSetCaseInsensitive(stablecoinsForChain, toTokenAddress),
788+
);
789+
790+
const slippageForFetch = isStableTokenPair ? Slippage.stable : maxSlippage;
791+
761792
trackEvent({
762793
event: MetaMetricsEventName.QuotesRequested,
763794
category: MetaMetricsEventCategory.Swaps,
@@ -766,8 +797,10 @@ export const fetchQuotesAndSetQuoteState = (
766797
token_from_amount: String(inputValue),
767798
token_to: toTokenSymbol,
768799
request_type: balanceError ? 'Quote' : 'Order',
769-
slippage: maxSlippage,
770-
custom_slippage: maxSlippage !== Slippage.default,
800+
slippage: slippageForFetch,
801+
custom_slippage:
802+
slippageForFetch !== Slippage.default &&
803+
slippageForFetch !== Slippage.stable,
771804
is_hardware_wallet: hardwareWalletUsed,
772805
hardware_wallet_type: hardwareWalletType,
773806
stx_enabled: smartTransactionsEnabled,
@@ -787,7 +820,7 @@ export const fetchQuotesAndSetQuoteState = (
787820
const fetchAndSetQuotesPromise = dispatch(
788821
fetchAndSetQuotes(
789822
{
790-
slippage: maxSlippage,
823+
slippage: slippageForFetch,
791824
sourceToken: fromTokenAddress,
792825
destinationToken: toTokenAddress,
793826
value: inputValue,
@@ -825,8 +858,10 @@ export const fetchQuotesAndSetQuoteState = (
825858
token_from_amount: String(inputValue),
826859
token_to: toTokenSymbol,
827860
request_type: balanceError ? 'Quote' : 'Order',
828-
slippage: maxSlippage,
829-
custom_slippage: maxSlippage !== Slippage.default,
861+
slippage: slippageForFetch,
862+
custom_slippage:
863+
slippageForFetch !== Slippage.default &&
864+
slippageForFetch !== Slippage.stable,
830865
is_hardware_wallet: hardwareWalletUsed,
831866
hardware_wallet_type: hardwareWalletType,
832867
stx_enabled: smartTransactionsEnabled,
@@ -859,8 +894,10 @@ export const fetchQuotesAndSetQuoteState = (
859894
token_to: toTokenSymbol,
860895
token_to_amount: tokenToAmountToString,
861896
request_type: balanceError ? 'Quote' : 'Order',
862-
slippage: maxSlippage,
863-
custom_slippage: maxSlippage !== Slippage.default,
897+
slippage: slippageForFetch,
898+
custom_slippage:
899+
slippageForFetch !== Slippage.default &&
900+
slippageForFetch !== Slippage.stable,
864901
response_time: Date.now() - fetchStartTime,
865902
best_quote_source: newSelectedQuote.aggregator,
866903
available_quotes: Object.values(fetchedQuotes)?.length,

ui/pages/swaps/prepare-swap-page/prepare-swap-page.js

+2
Original file line numberDiff line numberDiff line change
@@ -1219,6 +1219,8 @@ export default function PrepareSwapPage({
12191219
onModalClose={() => {
12201220
dispatch(setTransactionSettingsOpened(false));
12211221
}}
1222+
sourceTokenSymbol={fromToken?.symbol}
1223+
destinationTokenSymbol={toToken?.symbol}
12221224
/>
12231225
)}
12241226
{showQuotesLoadingAnimation && (

ui/pages/swaps/transaction-settings/transaction-settings.js

+20-2
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
Slippage,
2323
SLIPPAGE_VERY_HIGH_ERROR,
2424
SLIPPAGE_NEGATIVE_ERROR,
25+
isStablePair,
2526
} from '../../../../shared/constants/swaps';
2627
import {
2728
BannerAlert,
@@ -40,6 +41,8 @@ export default function TransactionSettings({
4041
maxAllowedSlippage,
4142
currentSlippage,
4243
isDirectWrappingEnabled,
44+
sourceTokenSymbol,
45+
destinationTokenSymbol,
4346
}) {
4447
const t = useContext(I18nContext);
4548
const dispatch = useDispatch();
@@ -59,6 +62,8 @@ export default function TransactionSettings({
5962
return 1; // 3% slippage.
6063
} else if (currentSlippage === Slippage.default) {
6164
return 0; // 2% slippage.
65+
} else if (currentSlippage === Slippage.stable) {
66+
return 0; // 0.5% slippage for stable pairs.
6267
} else if (typeof currentSlippage === 'number') {
6368
return 2; // Custom slippage.
6469
}
@@ -185,10 +190,21 @@ export default function TransactionSettings({
185190
setCustomValue('');
186191
setEnteringCustomValue(false);
187192
setActiveButtonIndex(0);
188-
setNewSlippage(Slippage.default);
193+
setNewSlippage(
194+
isStablePair(
195+
sourceTokenSymbol,
196+
destinationTokenSymbol,
197+
)
198+
? Slippage.stable
199+
: Slippage.default,
200+
);
189201
}}
190202
>
191-
{t('swapSlippagePercent', [Slippage.default])}
203+
{t('swapSlippagePercent', [
204+
isStablePair(sourceTokenSymbol, destinationTokenSymbol)
205+
? Slippage.stable
206+
: Slippage.default,
207+
])}
192208
</Button>
193209
<Button
194210
onClick={() => {
@@ -301,4 +317,6 @@ TransactionSettings.propTypes = {
301317
maxAllowedSlippage: PropTypes.number.isRequired,
302318
currentSlippage: PropTypes.number,
303319
isDirectWrappingEnabled: PropTypes.bool,
320+
sourceTokenSymbol: PropTypes.string,
321+
destinationTokenSymbol: PropTypes.string,
304322
};

0 commit comments

Comments
 (0)