Skip to content
Merged
5 changes: 5 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion packages/bridge-controller/src/constants/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion packages/bridge-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -130,6 +129,7 @@ export {

export {
selectBridgeQuotes,
selectDefaultSlippagePercentage,
type BridgeAppState,
selectExchangeRateByChainIdAndAddress,
selectIsQuoteExpired,
Expand All @@ -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';
202 changes: 202 additions & 0 deletions packages/bridge-controller/src/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
selectBridgeQuotes,
selectIsQuoteExpired,
selectBridgeFeatureFlags,
selectDefaultSlippagePercentage,
} from './selectors';
import type { BridgeAsset, QuoteResponse } from './types';
import { SortOrder, RequestStatus, ChainId } from './types';
Expand Down Expand Up @@ -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);
});
});
});
37 changes: 37 additions & 0 deletions packages/bridge-controller/src/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import {
calcTotalEstimatedNetworkFee,
calcTotalMaxNetworkFee,
} from './utils/quote';
import { getDefaultSlippagePercentage } from './utils/slippage';

/**
* The controller states that provide exchange rates
Expand Down Expand Up @@ -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<typeof getDefaultSlippagePercentage>[0]) =>
slippageParams.srcTokenAddress,
(_, slippageParams: Parameters<typeof getDefaultSlippagePercentage>[0]) =>
slippageParams.destTokenAddress,
(_, slippageParams: Parameters<typeof getDefaultSlippagePercentage>[0]) =>
slippageParams.srcChainId
? formatChainIdToCaip(slippageParams.srcChainId)
: undefined,
(_, slippageParams: Parameters<typeof getDefaultSlippagePercentage>[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,
);
},
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Chain ID Mismatch Causes Incorrect Slippage

The selectDefaultSlippagePercentage selector passes CAIP-formatted chain IDs to getDefaultSlippagePercentage. This function expects original numeric chain IDs for its internal logic (like isCrossChain and isSolanaChainId), which can lead to incorrect slippage calculations.

Fix in Cursor Fix in Web

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inaccurate

);
6 changes: 6 additions & 0 deletions packages/bridge-controller/src/utils/metrics/properties.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
66 changes: 66 additions & 0 deletions packages/bridge-controller/src/utils/slippage.ts
Original file line number Diff line number Diff line change
@@ -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;
};
1 change: 1 addition & 0 deletions packages/bridge-controller/src/utils/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
Loading