Skip to content

Commit de4d785

Browse files
authored
feat(STX-331): extract refresh STX liveness from swaps (#38877)
<!-- 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** Separate smart transaction liveness refresh from swap flags. This is part of the migration of STX flags away from swaps. <!-- 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/38877?quickstart=1) ## **Changelog** CHANGELOG entry: Added network specific smart transactions liveness check before submitting bridge quotes. <!-- 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) --> ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to this page... 2. 3. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [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** - [ ] 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] > Separates Smart Transactions (STX) liveness from swaps and makes it chain-specific. > > - Replace global `smartTransactionsState.liveness` with `livenessByChainId`; update `getSmartTransactionsEnabled` to use effective chain ID > - New `useRefreshSmartTransactionsLiveness` hook; invoked by `ui/pages/bridge/index.tsx` to fetch liveness for allowed EVM chains on load/chain change > - Update `useSmartTransactionFeatureFlags` to gate by allowed chain IDs and call `fetchSmartTransactionsLiveness({ chainId })` > - `fetchSmartTransactionsLiveness` now accepts `chainId` (deprecates `networkClientId`) and forwards both to background > - Remove liveness fetch from swaps feature-flag flow; adjust related tests/dispatch counts > - Add `livenessByChainId` to test fixtures and integration init state; update selector tests accordingly > - Bump `@metamask/smart-transactions-controller` to `^21.1.0` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 444d0e7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 78adf4f commit de4d785

File tree

14 files changed

+185
-33
lines changed

14 files changed

+185
-33
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -371,7 +371,7 @@
371371
"@metamask/selected-network-controller": "^25.0.0",
372372
"@metamask/shield-controller": "^4.1.0",
373373
"@metamask/signature-controller": "^38.0.0",
374-
"@metamask/smart-transactions-controller": "^21.0.0",
374+
"@metamask/smart-transactions-controller": "^21.1.0",
375375
"@metamask/snaps-controllers": "^17.2.0",
376376
"@metamask/snaps-execution-environments": "^10.3.0",
377377
"@metamask/snaps-rpc-methods": "^14.1.1",

shared/modules/selectors/index.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,12 @@ describe('Selectors', () => {
5959
},
6060
smartTransactionsState: {
6161
liveness: true,
62+
livenessByChainId: {
63+
[CHAIN_IDS.MAINNET]: true,
64+
[CHAIN_IDS.BSC]: true,
65+
[CHAIN_IDS.SEPOLIA]: true,
66+
[CHAIN_IDS.LINEA_MAINNET]: true,
67+
},
6268
},
6369
...mockNetworkState({
6470
id: 'network-configuration-id-1',
@@ -195,10 +201,22 @@ describe('Selectors', () => {
195201
);
196202

197203
jestIt(
198-
'returns false if feature flag is enabled, not a HW, STX liveness is false and is Ethereum network',
204+
'returns false if feature flag is enabled, not a HW, STX liveness is false for chain',
205+
() => {
206+
const state = createSwapsMockStore();
207+
state.metamask.smartTransactionsState.livenessByChainId[
208+
CHAIN_IDS.MAINNET
209+
] = false;
210+
expect(getSmartTransactionsEnabled(state)).toBe(false);
211+
},
212+
);
213+
214+
jestIt(
215+
'returns false if feature flag is enabled, not a HW, STX liveness is not set for chain',
199216
() => {
200217
const state = createSwapsMockStore();
201-
state.metamask.smartTransactionsState.liveness = false;
218+
// @ts-expect-error Testing undefined liveness for chain
219+
state.metamask.smartTransactionsState.livenessByChainId = {};
202220
expect(getSmartTransactionsEnabled(state)).toBe(false);
203221
},
204222
);

shared/modules/selectors/smart-transactions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export type SmartTransactionsMetaMaskState = {
5151
};
5252
smartTransactionsState: {
5353
liveness: boolean;
54+
livenessByChainId: Record<string, boolean>;
5455
};
5556
};
5657
};
@@ -176,14 +177,17 @@ export const getSmartTransactionsEnabled = (
176177
state: SmartTransactionsState,
177178
chainId?: string,
178179
): boolean => {
180+
const effectiveChainId = chainId ?? getCurrentChainId(state);
179181
const supportedAccount = accountSupportsSmartTx(state);
180182
// @ts-expect-error Smart transaction selector types does not match controller state
181183
const featureFlagsByChainId = getFeatureFlagsByChainId(state, chainId);
182184
// TODO: Create a new proxy service only for MM feature flags.
183185
const smartTransactionsFeatureFlagEnabled =
184186
featureFlagsByChainId?.smartTransactions?.extensionActive;
185187
const smartTransactionsLiveness =
186-
state.metamask.smartTransactionsState?.liveness;
188+
state.metamask.smartTransactionsState?.livenessByChainId?.[
189+
effectiveChainId
190+
];
187191
return Boolean(
188192
getChainSupportsSmartTransactions(state, chainId) &&
189193
getIsAllowedRpcUrlForSmartTransactions(state, chainId) &&

test/integration/data/integration-init-state.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,6 +874,10 @@
874874
"smartTransactionsState": {
875875
"fees": {},
876876
"liveness": true,
877+
"livenessByChainId": {
878+
"0x1": true,
879+
"0xaa36a7": true
880+
},
877881
"smartTransactions": {
878882
"0x1": [],
879883
"0xaa36a7": []

test/jest/mock-store.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,12 @@ export const createSwapsMockStore = () => {
661661
userOptIn: true,
662662
userOptInV2: true,
663663
liveness: true,
664+
livenessByChainId: {
665+
[CHAIN_IDS.MAINNET]: true,
666+
[CHAIN_IDS.BSC]: true,
667+
[CHAIN_IDS.SEPOLIA]: true,
668+
[CHAIN_IDS.LINEA_MAINNET]: true,
669+
},
664670
fees: createGetSmartTransactionFeesApiResponse(),
665671
smartTransactions: {
666672
[CHAIN_IDS.MAINNET]: [

ui/ducks/bridge/selectors.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1913,6 +1913,9 @@ describe('Bridge selectors', () => {
19131913
},
19141914
smartTransactionsState: {
19151915
liveness: true,
1916+
livenessByChainId: {
1917+
'0x1': true,
1918+
},
19161919
},
19171920
swapsState: {
19181921
swapsFeatureFlags: {
@@ -1950,6 +1953,9 @@ describe('Bridge selectors', () => {
19501953
},
19511954
smartTransactionsState: {
19521955
liveness: true,
1956+
livenessByChainId: {
1957+
'0x1': true,
1958+
},
19531959
},
19541960
swapsState: {
19551961
swapsFeatureFlags: {

ui/ducks/swaps/swaps.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ import {
2222
setSwapsFeatureFlags,
2323
setSelectedQuoteAggId,
2424
setSwapsTxGasLimit,
25-
fetchSmartTransactionsLiveness,
2625
signAndSendSmartTransaction,
2726
updateSmartTransaction,
2827
setSmartTransactionsRefreshInterval,
@@ -599,11 +598,6 @@ export const fetchSwapsLivenessAndFeatureFlags = () => {
599598
await dispatch(setSwapsFeatureFlags(swapsFeatureFlags));
600599
if (ALLOWED_SMART_TRANSACTIONS_CHAIN_IDS.includes(chainId)) {
601600
await dispatch(setCurrentSmartTransactionsError(undefined));
602-
await dispatch(
603-
fetchSmartTransactionsLiveness({
604-
networkClientId: getSelectedNetworkClientId(state),
605-
}),
606-
);
607601
const transactions = await getTransactions({
608602
searchCriteria: {
609603
chainId,

ui/ducks/swaps/swaps.test.js

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ const middleware = [thunk];
1515
jest.mock('../../store/actions.ts', () => ({
1616
setSwapsLiveness: jest.fn(),
1717
setSwapsFeatureFlags: jest.fn(),
18-
fetchSmartTransactionsLiveness: jest.fn(),
1918
getTransactions: jest.fn(() => {
2019
return [];
2120
}),
@@ -85,7 +84,7 @@ describe('Ducks - Swaps', () => {
8584
createGetState(),
8685
);
8786
expect(featureFlagApiNock.isDone()).toBe(true);
88-
expect(mockDispatch).toHaveBeenCalledTimes(5);
87+
expect(mockDispatch).toHaveBeenCalledTimes(4);
8988
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
9089
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
9190
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
@@ -106,7 +105,7 @@ describe('Ducks - Swaps', () => {
106105
createGetState(),
107106
);
108107
expect(featureFlagApiNock.isDone()).toBe(true);
109-
expect(mockDispatch).toHaveBeenCalledTimes(5);
108+
expect(mockDispatch).toHaveBeenCalledTimes(4);
110109
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
111110
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
112111
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
@@ -128,7 +127,7 @@ describe('Ducks - Swaps', () => {
128127
createGetState(),
129128
);
130129
expect(featureFlagApiNock.isDone()).toBe(true);
131-
expect(mockDispatch).toHaveBeenCalledTimes(5);
130+
expect(mockDispatch).toHaveBeenCalledTimes(4);
132131
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
133132
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
134133
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
@@ -174,7 +173,7 @@ describe('Ducks - Swaps', () => {
174173
createGetState(),
175174
);
176175
expect(featureFlagApiNock2.isDone()).toBe(false); // Second API call wasn't made, cache was used instead.
177-
expect(mockDispatch).toHaveBeenCalledTimes(10);
176+
expect(mockDispatch).toHaveBeenCalledTimes(8);
178177
expect(setSwapsLiveness).toHaveBeenCalledWith(expectedSwapsLiveness);
179178
expect(setSwapsFeatureFlags).toHaveBeenCalledWith(featureFlagsResponse);
180179
expect(swapsLiveness).toMatchObject(expectedSwapsLiveness);
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { renderHook } from '@testing-library/react-hooks';
2+
import * as reactRedux from 'react-redux';
3+
import { CHAIN_IDS } from '@metamask/transaction-controller';
4+
import { useRefreshSmartTransactionsLiveness } from './useRefreshSmartTransactionsLiveness';
5+
6+
const mockInnerFn = jest.fn();
7+
8+
jest.mock('react-redux', () => ({
9+
useSelector: jest.fn(),
10+
}));
11+
12+
jest.mock('../../../store/actions', () => ({
13+
fetchSmartTransactionsLiveness: jest.fn(() => mockInnerFn),
14+
}));
15+
16+
const mockFetchSmartTransactionsLiveness = jest.requireMock(
17+
'../../../store/actions',
18+
).fetchSmartTransactionsLiveness;
19+
20+
describe('useRefreshSmartTransactionsLiveness', () => {
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
(reactRedux.useSelector as jest.Mock).mockReturnValue(true); // opted in by default
24+
});
25+
26+
it('does not fetch when chainId is null', () => {
27+
renderHook(() => useRefreshSmartTransactionsLiveness(null));
28+
expect(mockFetchSmartTransactionsLiveness).not.toHaveBeenCalled();
29+
});
30+
31+
it('does not fetch when chainId is undefined', () => {
32+
renderHook(() => useRefreshSmartTransactionsLiveness(undefined));
33+
expect(mockFetchSmartTransactionsLiveness).not.toHaveBeenCalled();
34+
});
35+
36+
it('does not fetch for non-EVM chains', () => {
37+
renderHook(() => useRefreshSmartTransactionsLiveness('solana:mainnet'));
38+
expect(mockFetchSmartTransactionsLiveness).not.toHaveBeenCalled();
39+
});
40+
41+
it('does not fetch for unsupported EVM chains', () => {
42+
renderHook(() => useRefreshSmartTransactionsLiveness('0x999'));
43+
expect(mockFetchSmartTransactionsLiveness).not.toHaveBeenCalled();
44+
});
45+
46+
it('does not fetch when user has not opted in', () => {
47+
(reactRedux.useSelector as jest.Mock).mockReturnValue(false);
48+
renderHook(() => useRefreshSmartTransactionsLiveness(CHAIN_IDS.MAINNET));
49+
expect(mockFetchSmartTransactionsLiveness).not.toHaveBeenCalled();
50+
});
51+
52+
it('fetches smart transactions liveness for mainnet', () => {
53+
renderHook(() => useRefreshSmartTransactionsLiveness(CHAIN_IDS.MAINNET));
54+
expect(mockFetchSmartTransactionsLiveness).toHaveBeenCalledTimes(1);
55+
expect(mockFetchSmartTransactionsLiveness).toHaveBeenCalledWith({
56+
chainId: CHAIN_IDS.MAINNET,
57+
});
58+
expect(mockInnerFn).toHaveBeenCalledTimes(1);
59+
});
60+
61+
it('re-fetches when chainId changes to another supported chain', () => {
62+
const { rerender } = renderHook<{ chainId: string }, void>(
63+
({ chainId }) => useRefreshSmartTransactionsLiveness(chainId),
64+
{ initialProps: { chainId: CHAIN_IDS.MAINNET } },
65+
);
66+
67+
expect(mockFetchSmartTransactionsLiveness).toHaveBeenCalledTimes(1);
68+
expect(mockInnerFn).toHaveBeenCalledTimes(1);
69+
70+
// BSC is in both production and development allowed lists
71+
rerender({ chainId: CHAIN_IDS.BSC });
72+
expect(mockFetchSmartTransactionsLiveness).toHaveBeenCalledTimes(2);
73+
expect(mockInnerFn).toHaveBeenCalledTimes(2);
74+
});
75+
});
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { useEffect } from 'react';
2+
import { useSelector } from 'react-redux';
3+
import { getAllowedSmartTransactionsChainIds } from '../../../../shared/constants/smartTransactions';
4+
import { getSmartTransactionsPreferenceEnabled } from '../../../../shared/modules/selectors';
5+
import { fetchSmartTransactionsLiveness } from '../../../store/actions';
6+
import { isNonEvmChain } from '../../../ducks/bridge/utils';
7+
8+
/**
9+
* Hook that fetches smart transactions liveness for a given chain.
10+
* Ensures fresh liveness data is fetched when entering the page
11+
* and when the chain changes.
12+
*
13+
* @param chainId - The chain ID to check for STX support (string or null/undefined).
14+
*/
15+
export function useRefreshSmartTransactionsLiveness(
16+
chainId: string | null | undefined,
17+
): void {
18+
const smartTransactionsOptInStatus = useSelector(
19+
getSmartTransactionsPreferenceEnabled,
20+
);
21+
22+
useEffect(() => {
23+
if (!chainId || !smartTransactionsOptInStatus) {
24+
return;
25+
}
26+
27+
if (isNonEvmChain(chainId)) {
28+
return;
29+
}
30+
31+
// TODO: will be replaced with feature flags once we have them.
32+
const allowedChainId = getAllowedSmartTransactionsChainIds().find(
33+
(id) => id === chainId,
34+
);
35+
36+
if (allowedChainId) {
37+
fetchSmartTransactionsLiveness({ chainId: allowedChainId })();
38+
}
39+
}, [chainId, smartTransactionsOptInStatus]);
40+
}

0 commit comments

Comments
 (0)