Skip to content

Commit 92f70c1

Browse files
runway-github[bot]satyajeetkolhapuremetamaskbot
authored
chore(runway): cherry-pick feat: auto slippage support for RWA tokens cp-7.76.0 (#29664)
- feat: auto slippage support for RWA tokens cp-7.76.0 (#29592) <!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **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 adds support for auto slippage for RWA tokens COW swap liquidity is volatile and to address it, cow swap provides dynamic slippage suggestion service in there SDK. Bridge api is using it. On extension and mobile, we are providing auto option by default when one or both tokens are RWA (same is implemented for solana tokens) When it is auto, dynamic slippage is applied. We also want user to have freedom to choose slippage. Hence we have kept existing options to choose along with auto. ## **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: Added auto slippage support for RWA tokens ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="482" height="761" alt="image" src="https://github.com/user-attachments/assets/ffc63af5-3d7a-485a-863e-941e5b545458" /> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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] > **Medium Risk** > Changes default slippage behavior for same-chain EVM swaps when a stock-class RWA token is involved, which can affect quote execution outcomes and UX. Risk is mitigated by gating on the RWA remote feature flag and adding targeted unit/integration tests. > > **Overview** > Adds a new `selectIsRwaSwap` selector (same-chain EVM + stock RWA token + RWA flag enabled) and uses it to treat those swaps like Solana same-chain swaps for slippage. > > When `selectIsRwaSwap` is true, `useInitialSlippage` now initializes slippage to `DEFAULT_SLIPPAGE_RWA` (*undefined* → provider dynamic slippage) and `useSlippageConfig` injects an `auto` preset (`['auto','0.5','2']`) into the slippage options. > > Refactors stock-RWA detection into shared `isStockRwaBridgeToken`, forwards `rwaData` through Token Details swap navigation, and adds/updates tests covering the new selector and slippage behavior (including a BridgeView RWA swap case). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2bec59e. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: metamaskbot <metamaskbot@users.noreply.github.com> [355c9b6](355c9b6) Co-authored-by: Satyajeet Kolhapure <77279246+satyajeetkolhapure@users.noreply.github.com> Co-authored-by: metamaskbot <metamaskbot@users.noreply.github.com>
1 parent 5f21b3c commit 92f70c1

13 files changed

Lines changed: 501 additions & 15 deletions

File tree

app/components/UI/Bridge/Views/BridgeView/BridgeView.test.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { RootState } from '../../../../../reducers';
3434
import { mockQuoteWithMetadata } from '../../_mocks_/bridgeQuoteWithMetadata';
3535
import { BridgeTrendingTokensSectionTestIds } from '../../components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.testIds';
3636
import { Button } from '@metamask/design-system-react-native';
37+
import { FEATURE_FLAG_NAME } from '../../../../../selectors/featureFlagController/rwa';
3738

3839
// Mock the account-tree-controller file that imports the problematic module
3940
jest.mock(
@@ -852,6 +853,74 @@ describe('BridgeView', () => {
852853
});
853854
});
854855

856+
describe('RWA same-chain EVM swap', () => {
857+
it('sets slippage to undefined for stock RWA swap with RWA flag enabled', async () => {
858+
const mockQuote = mockQuoteWithMetadata;
859+
const ethChainId = '0x1' as const;
860+
const testState = createBridgeTestState(
861+
{
862+
bridgeControllerOverrides: {
863+
quoteRequest: {
864+
insufficientBal: false,
865+
},
866+
quotesLoadingStatus: RequestStatus.FETCHED,
867+
quotes: [mockQuote as unknown as QuoteResponse],
868+
},
869+
bridgeReducerOverrides: {
870+
sourceAmount: '1.0',
871+
sourceToken: {
872+
address: '0x0000000000000000000000000000000000000001',
873+
chainId: ethChainId,
874+
decimals: 18,
875+
image: '',
876+
name: 'Token A',
877+
symbol: 'TKA',
878+
rwaData: { instrumentType: 'stock' } as BridgeToken['rwaData'],
879+
},
880+
destToken: {
881+
address: '0x0000000000000000000000000000000000000002',
882+
chainId: ethChainId,
883+
decimals: 6,
884+
image: '',
885+
name: 'USDC',
886+
symbol: 'USDC',
887+
},
888+
},
889+
},
890+
{
891+
...initialState,
892+
engine: {
893+
...initialState.engine,
894+
backgroundState: {
895+
...initialState.engine.backgroundState,
896+
RemoteFeatureFlagController: {
897+
...initialState.engine.backgroundState
898+
.RemoteFeatureFlagController,
899+
remoteFeatureFlags: {
900+
...initialState.engine.backgroundState
901+
.RemoteFeatureFlagController.remoteFeatureFlags,
902+
[FEATURE_FLAG_NAME]: true,
903+
},
904+
},
905+
},
906+
},
907+
},
908+
);
909+
910+
const { store } = renderScreen(
911+
BridgeView,
912+
{
913+
name: Routes.BRIDGE.ROOT,
914+
},
915+
{ state: testState },
916+
);
917+
918+
await waitFor(() => {
919+
expect(store.getState().bridge.slippage).toBeUndefined();
920+
});
921+
});
922+
});
923+
855924
describe('Bottom Content', () => {
856925
beforeEach(() => {
857926
jest.clearAllMocks();
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider';
2+
import { waitFor } from '@testing-library/react-native';
3+
import { useInitialSlippage } from '.';
4+
import {
5+
selectIsSolanaSwap,
6+
selectIsRwaSwap,
7+
selectIsBridge,
8+
selectIsEvmSwap,
9+
selectSourceToken,
10+
selectDestToken,
11+
setSlippage,
12+
} from '../../../../../core/redux/slices/bridge';
13+
import AppConstants from '../../../../../core/AppConstants';
14+
import { initialState } from '../../_mocks_/initialState';
15+
16+
jest.mock('../../../../../core/redux/slices/bridge', () => {
17+
const actual = jest.requireActual('../../../../../core/redux/slices/bridge');
18+
return {
19+
__esModule: true,
20+
...actual,
21+
default: actual.default,
22+
setSlippage: jest.fn(actual.setSlippage),
23+
selectIsSolanaSwap: jest.fn(),
24+
selectIsRwaSwap: jest.fn(),
25+
selectIsBridge: jest.fn(),
26+
selectIsEvmSwap: jest.fn(),
27+
selectSourceToken: jest.fn(),
28+
selectDestToken: jest.fn(),
29+
};
30+
});
31+
32+
const mockSelectIsSolanaSwap = selectIsSolanaSwap as jest.MockedFunction<
33+
typeof selectIsSolanaSwap
34+
>;
35+
const mockSelectIsRwaSwap = selectIsRwaSwap as jest.MockedFunction<
36+
typeof selectIsRwaSwap
37+
>;
38+
const mockSelectIsBridge = selectIsBridge as jest.MockedFunction<
39+
typeof selectIsBridge
40+
>;
41+
const mockSelectIsEvmSwap = selectIsEvmSwap as jest.MockedFunction<
42+
typeof selectIsEvmSwap
43+
>;
44+
const mockSelectSourceToken = selectSourceToken as jest.MockedFunction<
45+
typeof selectSourceToken
46+
>;
47+
const mockSelectDestToken = selectDestToken as jest.MockedFunction<
48+
typeof selectDestToken
49+
>;
50+
51+
describe('useInitialSlippage', () => {
52+
beforeEach(() => {
53+
jest.clearAllMocks();
54+
mockSelectIsSolanaSwap.mockReturnValue(false as never);
55+
mockSelectIsRwaSwap.mockReturnValue(false as never);
56+
mockSelectIsBridge.mockReturnValue(false as never);
57+
mockSelectIsEvmSwap.mockReturnValue(false as never);
58+
mockSelectSourceToken.mockReturnValue(undefined as never);
59+
mockSelectDestToken.mockReturnValue(undefined as never);
60+
});
61+
62+
describe('Solana swap', () => {
63+
it('dispatches DEFAULT_SLIPPAGE_SOLANA when isSolanaSwap is true', async () => {
64+
mockSelectIsSolanaSwap.mockReturnValue(true as never);
65+
66+
renderHookWithProvider(() => useInitialSlippage(), {
67+
state: initialState,
68+
});
69+
70+
await waitFor(() => {
71+
expect(setSlippage).toHaveBeenCalledWith(
72+
AppConstants.SWAPS.DEFAULT_SLIPPAGE_SOLANA,
73+
);
74+
});
75+
});
76+
});
77+
78+
describe('RWA swap', () => {
79+
it('dispatches DEFAULT_SLIPPAGE_RWA (undefined) when isRwaSwap is true', async () => {
80+
mockSelectIsRwaSwap.mockReturnValue(true as never);
81+
82+
renderHookWithProvider(() => useInitialSlippage(), {
83+
state: initialState,
84+
});
85+
86+
await waitFor(() => {
87+
expect(setSlippage).toHaveBeenCalledWith(
88+
AppConstants.SWAPS.DEFAULT_SLIPPAGE_RWA,
89+
);
90+
});
91+
});
92+
93+
it('Solana swap takes priority over RWA swap — setSlippage called exactly once', async () => {
94+
mockSelectIsSolanaSwap.mockReturnValue(true as never);
95+
mockSelectIsRwaSwap.mockReturnValue(true as never);
96+
97+
renderHookWithProvider(() => useInitialSlippage(), {
98+
state: initialState,
99+
});
100+
101+
await waitFor(() => {
102+
// The Solana branch returns early, so the RWA branch never runs
103+
expect(setSlippage).toHaveBeenCalledTimes(1);
104+
expect(setSlippage).toHaveBeenCalledWith(
105+
AppConstants.SWAPS.DEFAULT_SLIPPAGE_SOLANA,
106+
);
107+
});
108+
});
109+
});
110+
111+
describe('EVM swap', () => {
112+
it('dispatches DEFAULT_SLIPPAGE for a regular EVM swap', async () => {
113+
mockSelectIsEvmSwap.mockReturnValue(true as never);
114+
mockSelectSourceToken.mockReturnValue({
115+
address: '0xaaaa',
116+
chainId: '0x1',
117+
decimals: 18,
118+
symbol: 'TKA',
119+
} as never);
120+
mockSelectDestToken.mockReturnValue({
121+
address: '0xbbbb',
122+
chainId: '0x1',
123+
decimals: 6,
124+
symbol: 'USDC',
125+
} as never);
126+
127+
renderHookWithProvider(() => useInitialSlippage(), {
128+
state: initialState,
129+
});
130+
131+
await waitFor(() => {
132+
expect(setSlippage).toHaveBeenCalledWith(
133+
AppConstants.SWAPS.DEFAULT_SLIPPAGE.toString(),
134+
);
135+
});
136+
});
137+
});
138+
139+
describe('Bridge', () => {
140+
it('dispatches DEFAULT_SLIPPAGE_BRIDGE when isBridge is true', async () => {
141+
mockSelectIsBridge.mockReturnValue(true as never);
142+
143+
renderHookWithProvider(() => useInitialSlippage(), {
144+
state: initialState,
145+
});
146+
147+
await waitFor(() => {
148+
expect(setSlippage).toHaveBeenCalledWith(
149+
AppConstants.SWAPS.DEFAULT_SLIPPAGE_BRIDGE.toString(),
150+
);
151+
});
152+
});
153+
});
154+
});

app/components/UI/Bridge/hooks/useInitialSlippage/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
selectDestToken,
55
selectIsBridge,
66
selectIsEvmSwap,
7+
selectIsRwaSwap,
78
selectIsSolanaSwap,
89
selectSourceToken,
910
setSlippage,
@@ -15,6 +16,7 @@ import { getIsStablecoinPair } from '../useStablecoinsDefaultSlippage';
1516
export const useInitialSlippage = () => {
1617
const dispatch = useDispatch();
1718
const isSolanaSwap = useSelector(selectIsSolanaSwap);
19+
const isRwaSwap = useSelector(selectIsRwaSwap);
1820
const isBridge = useSelector(selectIsBridge);
1921
const isEvmSwap = useSelector(selectIsEvmSwap);
2022
const sourceToken = useSelector(selectSourceToken);
@@ -27,6 +29,10 @@ export const useInitialSlippage = () => {
2729
dispatch(setSlippage(AppConstants.SWAPS.DEFAULT_SLIPPAGE_SOLANA));
2830
return;
2931
}
32+
if (isRwaSwap) {
33+
dispatch(setSlippage(AppConstants.SWAPS.DEFAULT_SLIPPAGE_RWA));
34+
return;
35+
}
3036
// EVM Swaps
3137
if (
3238
isEvmSwap &&
@@ -61,6 +67,7 @@ export const useInitialSlippage = () => {
6167
}
6268
}, [
6369
isSolanaSwap,
70+
isRwaSwap,
6471
isBridge,
6572
dispatch,
6673
sourceToken?.address,

app/components/UI/Bridge/hooks/useRWAToken.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useCallback } from 'react';
22
import { selectRWAEnabledFlag } from '../../../../selectors/featureFlagController/rwa/index';
33
import { useSelector } from 'react-redux';
44
import { BridgeToken } from '../types';
5+
import { isStockRwaBridgeToken } from '../utils/isStockRwaBridgeToken';
56

67
export type DateLike = string | null | undefined | Date;
78

@@ -58,14 +59,7 @@ export function useRWAToken() {
5859
* @returns {boolean} - True if the token is a stock token, false otherwise
5960
*/
6061
const isStockToken = useCallback(
61-
(token?: BridgeToken) => {
62-
// If RWA is not enabled, always return false
63-
if (!isRWAEnabled) {
64-
return false;
65-
}
66-
67-
return Boolean(token?.rwaData?.instrumentType === 'stock');
68-
},
62+
(token?: BridgeToken) => isStockRwaBridgeToken(token, isRWAEnabled),
6963
[isRWAEnabled],
7064
);
7165

app/components/UI/Bridge/hooks/useSlippageConfig/index.test.tsx

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import { renderHook } from '@testing-library/react-hooks';
2+
import { useSelector } from 'react-redux';
23
import { useSlippageConfig } from './index';
34
import AppConstants from '../../../../../core/AppConstants';
5+
import { selectIsRwaSwap } from '../../../../../core/redux/slices/bridge';
6+
7+
jest.mock('react-redux', () => ({
8+
...jest.requireActual<typeof import('react-redux')>('react-redux'),
9+
useSelector: jest.fn(),
10+
}));
11+
12+
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
13+
14+
let mockIsRwaSwapFromStore = false;
415

516
describe('useSlippageConfig', () => {
617
const defaultConfig = AppConstants.BRIDGE.SLIPPAGE_CONFIG.__default__;
718

819
beforeEach(() => {
920
jest.clearAllMocks();
21+
mockIsRwaSwapFromStore = false;
22+
mockUseSelector.mockImplementation((selector) => {
23+
if (selector === selectIsRwaSwap) {
24+
return mockIsRwaSwapFromStore;
25+
}
26+
return undefined;
27+
});
1028
});
1129

1230
describe('returns default config', () => {
@@ -211,6 +229,36 @@ describe('useSlippageConfig', () => {
211229
expect(result.current.default_slippage_options[0]).toBe('auto');
212230
});
213231

232+
it('includes auto presets for same-chain EVM when selectIsRwaSwap is true', () => {
233+
mockIsRwaSwapFromStore = true;
234+
const { result } = renderHook(() =>
235+
useSlippageConfig({
236+
sourceChainId: 'eip155:1',
237+
destChainId: 'eip155:1',
238+
}),
239+
);
240+
241+
expect(result.current.default_slippage_options).toEqual([
242+
'auto',
243+
'0.5',
244+
'2',
245+
]);
246+
});
247+
248+
it('does not add auto for same-chain EVM when selectIsRwaSwap is false', () => {
249+
mockIsRwaSwapFromStore = false;
250+
const { result } = renderHook(() =>
251+
useSlippageConfig({
252+
sourceChainId: 'eip155:1',
253+
destChainId: 'eip155:1',
254+
}),
255+
);
256+
257+
expect(result.current.default_slippage_options).toEqual(
258+
defaultConfig.default_slippage_options,
259+
);
260+
});
261+
214262
it('preserves all other config values for Solana-to-Solana', () => {
215263
const { result } = renderHook(() =>
216264
useSlippageConfig({

0 commit comments

Comments
 (0)