diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 35cc8e6206e..792eeabb0dc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `selectDefaultSlippagePercentage` that returns the default slippage for a chain and token combination ([#6616](https://github.com/MetaMask/core/pull/6616)) + - Return `0.5` if requesting a bridge quote + - Return `undefined` (auto) if requesting a Solana swap + - Return `0.5` if both tokens are stablecoins (based on dynamic `stablecoins` list from LD chain config) + - Return `2` for all other EVM swaps - Add new controller metadata properties to `BridgeController` ([#6589](https://github.com/MetaMask/core/pull/6589)) ### Changed diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index c4ebdd73f70..9c57c7cd07f 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -37,7 +37,6 @@ export const BRIDGE_QUOTE_MAX_ETA_SECONDS = 60 * 60; // 1 hour export const BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE = 0.5; // if a quote returns in x times less return than the best quote, ignore it export const BRIDGE_PREFERRED_GAS_ESTIMATE = 'medium'; -export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; export const BRIDGE_MM_FEE_RATE = 0.875; export const REFRESH_INTERVAL_MS = 30 * 1000; export const DEFAULT_MAX_REFRESH_COUNT = 5; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 130e0ed8e2a..53f9ee0fa27 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -73,7 +73,6 @@ export { BRIDGE_QUOTE_MAX_ETA_SECONDS, BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, BRIDGE_PREFERRED_GAS_ESTIMATE, - BRIDGE_DEFAULT_SLIPPAGE, BRIDGE_MM_FEE_RATE, REFRESH_INTERVAL_MS, DEFAULT_MAX_REFRESH_COUNT, @@ -130,6 +129,7 @@ export { export { selectBridgeQuotes, + selectDefaultSlippagePercentage, type BridgeAppState, selectExchangeRateByChainIdAndAddress, selectIsQuoteExpired, @@ -140,3 +140,5 @@ export { export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge'; export { getBridgeFeatureFlags } from './utils/feature-flags'; + +export { BRIDGE_DEFAULT_SLIPPAGE } from './utils/slippage'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index 138b3831d42..f022d3c558d 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -11,6 +11,7 @@ import { selectBridgeQuotes, selectIsQuoteExpired, selectBridgeFeatureFlags, + selectDefaultSlippagePercentage, } from './selectors'; import type { BridgeAsset, QuoteResponse } from './types'; import { SortOrder, RequestStatus, ChainId } from './types'; @@ -1113,4 +1114,205 @@ describe('Bridge Selectors', () => { }); }); }); + + describe('selectDefaultSlippagePercentage', () => { + const mockValidBridgeConfig = { + minimumVersion: '0.0.0', + refreshRate: 3, + maxRefreshCount: 1, + support: true, + chains: { + '1': { + isActiveSrc: true, + isActiveDest: true, + stablecoins: ['0x123', '0x456'], + }, + '10': { + isActiveSrc: true, + isActiveDest: false, + }, + '1151111081099710': { + isActiveSrc: true, + isActiveDest: true, + }, + }, + }; + + it('should return swap default slippage when stablecoins list is not defined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + srcChainId: '10', + destChainId: '10', + }, + ); + + expect(result).toBe(2); + }); + + it('should return bridge default slippage when requesting an EVM bridge quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + srcChainId: '1', + destChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return bridge default slippage when requesting a Solana bridge quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap auto slippage when requesting a Solana swap quote', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + destChainId: ChainId.SOLANA, + srcChainId: ChainId.SOLANA, + }, + ); + + expect(result).toBeUndefined(); + }); + + it('should return swap default slippage when dest token is not a stablecoin', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x789', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + + it('should return swap default slippage when src token is not a stablecoin', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x789', + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + + it('should return swap stablecoin slippage when both tokens are stablecoins', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + destChainId: '1', + srcChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return bridge default slippage when srcChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + destChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap stablecoin slippage when destChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x123', + destTokenAddress: '0x456', + srcChainId: '1', + }, + ); + + expect(result).toBe(0.5); + }); + + it('should return swap default slippage when destChainId is undefined', () => { + const result = selectDefaultSlippagePercentage( + { + remoteFeatureFlags: { + bridgeConfig: mockValidBridgeConfig, + }, + } as never, + { + srcTokenAddress: '0x789', + destTokenAddress: '0x456', + srcChainId: '1', + }, + ); + + expect(result).toBe(2); + }); + }); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 56bb81db3e8..e1182abbef9 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -47,6 +47,7 @@ import { calcTotalEstimatedNetworkFee, calcTotalMaxNetworkFee, } from './utils/quote'; +import { getDefaultSlippagePercentage } from './utils/slippage'; /** * The controller states that provide exchange rates @@ -446,3 +447,39 @@ export const selectMinimumBalanceForRentExemptionInSOL = ( new BigNumber(state.minimumBalanceForRentExemptionInLamports ?? 0) .div(10 ** 9) .toString(); + +export const selectDefaultSlippagePercentage = createBridgeSelector( + [ + (state) => selectBridgeFeatureFlags(state).chains, + (_, slippageParams: Parameters[0]) => + slippageParams.srcTokenAddress, + (_, slippageParams: Parameters[0]) => + slippageParams.destTokenAddress, + (_, slippageParams: Parameters[0]) => + slippageParams.srcChainId + ? formatChainIdToCaip(slippageParams.srcChainId) + : undefined, + (_, slippageParams: Parameters[0]) => + slippageParams.destChainId + ? formatChainIdToCaip(slippageParams.destChainId) + : undefined, + ], + ( + featureFlagsByChain, + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + ) => { + return getDefaultSlippagePercentage( + { + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + }, + srcChainId ? featureFlagsByChain[srcChainId]?.stablecoins : undefined, + destChainId ? featureFlagsByChain[destChainId]?.stablecoins : undefined, + ); + }, +); diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 67ecb294f7a..8dc8b857697 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -98,6 +98,12 @@ export const isHardwareWallet = ( return selectedAccount?.metadata?.keyring.type?.includes('Hardware') ?? false; }; +/** + * @param slippage - The slippage percentage + * @returns Whether the default slippage was overridden by the user + * + * @deprecated This function should not be used. Use {@link selectDefaultSlippagePercentage} instead. + */ export const isCustomSlippage = (slippage: GenericQuoteRequest['slippage']) => { return slippage !== DEFAULT_BRIDGE_CONTROLLER_STATE.quoteRequest.slippage; }; diff --git a/packages/bridge-controller/src/utils/slippage.ts b/packages/bridge-controller/src/utils/slippage.ts new file mode 100644 index 00000000000..03affcf1106 --- /dev/null +++ b/packages/bridge-controller/src/utils/slippage.ts @@ -0,0 +1,66 @@ +import { isCrossChain, isSolanaChainId } from './bridge'; +import type { GenericQuoteRequest } from '../types'; + +export const BRIDGE_DEFAULT_SLIPPAGE = 0.5; +const SWAP_SOLANA_SLIPPAGE = undefined; +const SWAP_EVM_STABLECOIN_SLIPPAGE = 0.5; +const SWAP_EVM_DEFAULT_SLIPPAGE = 2; + +/** + * Calculates the appropriate slippage based on the transaction context + * + * Rules: + * - Bridge (cross-chain): Always 0.5% + * - Swap on Solana: Always undefined (AUTO mode) + * - Swap on EVM stablecoin pairs (same chain only): 0.5% + * - Swap on EVM other pairs: 2% + * + * @param options - the options for the destination chain + * @param options.srcTokenAddress - the source token address + * @param options.destTokenAddress - the destination token address + * @param options.srcChainId - the source chain id + * @param options.destChainId - the destination chain id + * @param srcStablecoins - the list of stablecoins on the source chain + * @param destStablecoins - the list of stablecoins on the destination chain + + * @returns the default slippage percentage for the chain and token pair + */ +export const getDefaultSlippagePercentage = ( + { + srcTokenAddress, + destTokenAddress, + srcChainId, + destChainId, + }: Partial< + Pick< + GenericQuoteRequest, + 'srcTokenAddress' | 'destTokenAddress' | 'srcChainId' | 'destChainId' + > + >, + srcStablecoins?: string[], + destStablecoins?: string[], +) => { + if (!srcChainId || isCrossChain(srcChainId, destChainId)) { + return BRIDGE_DEFAULT_SLIPPAGE; + } + + if (isSolanaChainId(srcChainId)) { + return SWAP_SOLANA_SLIPPAGE; + } + + if ( + srcTokenAddress && + destTokenAddress && + srcStablecoins + ?.map((stablecoin) => stablecoin.toLowerCase()) + .includes(srcTokenAddress.toLowerCase()) && + // If destChainId is undefined, treat req as a swap and fallback to srcStablecoins + (destStablecoins ?? srcStablecoins) + ?.map((stablecoin) => stablecoin.toLowerCase()) + .includes(destTokenAddress.toLowerCase()) + ) { + return SWAP_EVM_STABLECOIN_SLIPPAGE; + } + + return SWAP_EVM_DEFAULT_SLIPPAGE; +}; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 85a202ff5cd..d2124ee98cb 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -85,6 +85,7 @@ export const ChainConfigurationSchema = type({ isActiveDest: boolean(), refreshRate: optional(number()), topAssets: optional(array(string())), + stablecoins: optional(array(string())), isUnifiedUIEnabled: optional(boolean()), isSingleSwapBridgeButtonEnabled: optional(boolean()), isGaslessSwapEnabled: optional(boolean()),