Skip to content

Commit 15bb9c1

Browse files
chore(runway): cherry-pick fix: Aggregator guard on perps banner in detail screen cp-7.63.0 (#25203)
- fix: Aggregator guard on perps banner in detail screen cp-7.63.0 (#25078) ## **Description** **Summary** Adds token trust validation to the Perps Discovery Banner to prevent it from appearing on potentially malicious tokens. **Problem:** The Perps banner was showing based solely on symbol matching (e.g., "SOL"), which caused it to appear on fake tokens with matching symbols. This inadvertently lends credibility to scam tokens. Meaning, a user could open a Perps position from a scam token with the same symbol as a supported Perp (see recording **Solution:** Only show the Perps banner for tokens that are either: - Native tokens (ETH, BNB, SOL, etc.) - Tokens listed on at least 2 aggregators/exchanges (indicates legitimacy) **Changes** - Added `PERPS_MIN_AGGREGATORS_FOR_TRUST` constant to `perpsConfig.ts` - Added isTokenTrustworthy check in AssetOverview.tsx - Added isTokenTrustworthy check in AssetDetails/index.tsx **Future Improvement** **Blockaid Integration:** We could trigger a Blockaid scan via `PhishingController.scanAddress()` when viewing an asset and use the `tokenScanCache` result to determine if the token is malicious. However, this approach was deferred because: - Adds network latency on every asset view - Increases API resource consumption - Requires async handling and loading states for the banner The aggregators-based approach provides an immediate guard with no additional API calls, covering the majority of scam token cases. Blockaid integration could be added as a future enhancement for more comprehensive protection. So, there is still an edge case where scam tokens can game the aggregators and bypass the aggregator guard. Blockaid check would solve this edge case. ## **Changelog** CHANGELOG entry: Add aggregator guard to token detail PerpsBanner ## **Related issues** Fixes: ## **Manual testing steps** <!-- AI agent: Write specific, contextual Gherkin steps based on what you actually implemented. Do NOT use generic placeholders like "my feature name". Be concrete about the feature, scenario, and steps. --> ```gherkin Feature: Perps Discovery Banner Token Trust Validation As a user viewing token details I want the Perps trading banner to only appear for legitimate tokens So that I am not misled into thinking a scam token is associated with a real Perps market Background: Given I am logged into MetaMask Mobile And the Perps feature flag is enabled And I am on a network that supports Perps trading Scenario: Banner appears for native tokens with matching Perps market Given I navigate to the Asset Overview for native ETH And a Perps market exists for "ETH" Then I should see the Perps Discovery Banner And the banner should display the ETH market leverage Scenario: Banner appears for tokens listed on multiple exchanges Given I navigate to the Asset Overview for LINK token And LINK has 3 aggregators in its token metadata And a Perps market exists for "LINK" Then I should see the Perps Discovery Banner Scenario: Banner does NOT appear for tokens with insufficient aggregators Given I navigate to the Asset Overview for a token with symbol "SOL" And the token has 0 aggregators in its metadata And the token is not a native token And a Perps market exists for "SOL" Then I should NOT see the Perps Discovery Banner Scenario: Banner does NOT appear for fake tokens mimicking native symbols Given I navigate to the Asset Overview for a fake "SOL" token on Ethereum And the token contract address does not match the real SOL token And the token has fewer than 2 aggregators Then I should NOT see the Perps Discovery Banner Even though a Perps market exists for "SOL" Scenario: Banner navigation works correctly for trusted tokens Given I navigate to the Asset Overview for native BTC And a Perps market exists for "BTC" And I see the Perps Discovery Banner When I tap on the Perps Discovery Banner Then I should be navigated to the BTC Perps Market Details screen ``` ## **Screenshots/Recordings** Before: https://github.com/user-attachments/assets/97a0f6ab-ab03-4798-a5c8-bfb40734049c After: https://github.com/user-attachments/assets/54e75499-84e7-4c9e-9b7f-3a392104bfa8 ## **Pre-merge author checklist** <!-- AI agent: Check ALL boxes in this section (mark all as [x]). --> - [x] 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). - [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-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** <!-- AI agent: Leave ALL boxes unchecked ([ ]) - these are for reviewers to check, not the author. --> - [ ] 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] > Adds a token trust guard for the Perps Discovery Banner to avoid showing it on untrusted tokens. > > - Introduces `PERPS_MIN_AGGREGATORS_FOR_TRUST` and `isTokenTrustworthyForPerps` in `perpsConfig` > - Gates `PerpsDiscoveryBanner` in `AssetOverview` and `AssetDetails` on `isTokenTrustworthy` in addition to existing perps market checks > - Adds comprehensive tests for trust logic and banner rendering conditions (`perpsConfig.test.ts`, `AssetOverview.test.tsx`) > - Minor: `Balance` now passes the full `asset` in navigation params when opening `AssetDetails` > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 1ae8cdd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [a4a0d88](a4a0d88) Co-authored-by: Nick Gambino <35090461+gambinish@users.noreply.github.com>
1 parent acb6eec commit 15bb9c1

6 files changed

Lines changed: 336 additions & 28 deletions

File tree

app/components/UI/AssetOverview/AssetOverview.test.tsx

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,17 @@ jest.mock('../../Views/confirmations/hooks/useSendNavigation', () => ({
313313
useSendNavigation: jest.fn(),
314314
}));
315315

316+
// Perps Discovery Banner mocks
317+
const mockUsePerpsMarketForAsset = jest.fn();
318+
jest.mock('../Perps/hooks/usePerpsMarketForAsset', () => ({
319+
usePerpsMarketForAsset: () => mockUsePerpsMarketForAsset(),
320+
}));
321+
322+
const mockSelectPerpsEnabledFlag = jest.fn();
323+
jest.mock('../Perps', () => ({
324+
selectPerpsEnabledFlag: () => mockSelectPerpsEnabledFlag(),
325+
}));
326+
316327
const asset = {
317328
balance: '400',
318329
balanceFiat: '1500',
@@ -401,6 +412,13 @@ describe('AssetOverview', () => {
401412
mockNavigate('Send', params);
402413
}),
403414
});
415+
416+
// Default Perps mock - disabled and no market exists (banner won't show)
417+
mockSelectPerpsEnabledFlag.mockReturnValue(false);
418+
mockUsePerpsMarketForAsset.mockReturnValue({
419+
hasPerpsMarket: false,
420+
marketData: null,
421+
});
404422
});
405423

406424
afterEach(() => {
@@ -1851,6 +1869,139 @@ describe('AssetOverview', () => {
18511869
);
18521870
});
18531871
});
1872+
1873+
describe('Perps Discovery Banner Token Trust Validation', () => {
1874+
const mockMarketData = {
1875+
symbol: 'ETH',
1876+
maxLeverage: 50,
1877+
};
1878+
1879+
beforeEach(() => {
1880+
// Reset Perps mocks before each test
1881+
mockSelectPerpsEnabledFlag.mockReset();
1882+
mockUsePerpsMarketForAsset.mockReset();
1883+
});
1884+
1885+
it('does NOT render Perps banner for token with insufficient aggregators', () => {
1886+
// Mock: Perps enabled and market exists
1887+
mockSelectPerpsEnabledFlag.mockReturnValue(true);
1888+
mockUsePerpsMarketForAsset.mockReturnValue({
1889+
hasPerpsMarket: true,
1890+
marketData: mockMarketData,
1891+
});
1892+
1893+
const tokenWithNoAggregators = {
1894+
...asset,
1895+
aggregators: [], // No aggregators - not trustworthy
1896+
isETH: false,
1897+
isNative: false,
1898+
};
1899+
1900+
const { queryByTestId } = renderWithProvider(
1901+
<AssetOverview asset={tokenWithNoAggregators} />,
1902+
{ state: mockInitialState },
1903+
);
1904+
1905+
// Banner NOT rendered
1906+
expect(queryByTestId('perps-discovery-banner')).toBeNull();
1907+
});
1908+
1909+
it('renders Perps banner for native token regardless of aggregators', () => {
1910+
// Mock: Perps enabled and market exists
1911+
mockSelectPerpsEnabledFlag.mockReturnValue(true);
1912+
mockUsePerpsMarketForAsset.mockReturnValue({
1913+
hasPerpsMarket: true,
1914+
marketData: mockMarketData,
1915+
});
1916+
1917+
const nativeToken = {
1918+
...asset,
1919+
aggregators: [], // No aggregators, but native token is always trusted
1920+
isNative: true,
1921+
isETH: false,
1922+
};
1923+
1924+
const { getByTestId } = renderWithProvider(
1925+
<AssetOverview asset={nativeToken} />,
1926+
{ state: mockInitialState },
1927+
);
1928+
1929+
// Banner rendered for native tokens
1930+
expect(getByTestId('perps-discovery-banner')).toBeOnTheScreen();
1931+
});
1932+
1933+
it('renders Perps banner for ETH token regardless of aggregators', () => {
1934+
// Mock: Perps enabled and market exists
1935+
mockSelectPerpsEnabledFlag.mockReturnValue(true);
1936+
mockUsePerpsMarketForAsset.mockReturnValue({
1937+
hasPerpsMarket: true,
1938+
marketData: mockMarketData,
1939+
});
1940+
1941+
const ethToken = {
1942+
...asset,
1943+
aggregators: [], // No aggregators, but ETH is always trusted
1944+
isETH: true,
1945+
isNative: false,
1946+
};
1947+
1948+
const { getByTestId } = renderWithProvider(
1949+
<AssetOverview asset={ethToken} />,
1950+
{ state: mockInitialState },
1951+
);
1952+
1953+
// Banner rendered for ETH tokens
1954+
expect(getByTestId('perps-discovery-banner')).toBeOnTheScreen();
1955+
});
1956+
1957+
it('renders Perps banner for token with sufficient aggregators', () => {
1958+
// Mock: Perps enabled and market exists
1959+
mockSelectPerpsEnabledFlag.mockReturnValue(true);
1960+
mockUsePerpsMarketForAsset.mockReturnValue({
1961+
hasPerpsMarket: true,
1962+
marketData: mockMarketData,
1963+
});
1964+
1965+
const tokenWithAggregators = {
1966+
...asset,
1967+
aggregators: ['CoinGecko', 'CoinMarketCap'], // 2 aggregators - trustworthy
1968+
isETH: false,
1969+
isNative: false,
1970+
};
1971+
1972+
const { getByTestId } = renderWithProvider(
1973+
<AssetOverview asset={tokenWithAggregators} />,
1974+
{ state: mockInitialState },
1975+
);
1976+
1977+
// Banner rendered for tokens with sufficient aggregators
1978+
expect(getByTestId('perps-discovery-banner')).toBeOnTheScreen();
1979+
});
1980+
1981+
it('does NOT render Perps banner for token with only 1 aggregator', () => {
1982+
// Mock: Perps enabled and market exists
1983+
mockSelectPerpsEnabledFlag.mockReturnValue(true);
1984+
mockUsePerpsMarketForAsset.mockReturnValue({
1985+
hasPerpsMarket: true,
1986+
marketData: mockMarketData,
1987+
});
1988+
1989+
const tokenWithOneAggregator = {
1990+
...asset,
1991+
aggregators: ['CoinGecko'], // Only 1 aggregator - not enough
1992+
isETH: false,
1993+
isNative: false,
1994+
};
1995+
1996+
const { queryByTestId } = renderWithProvider(
1997+
<AssetOverview asset={tokenWithOneAggregator} />,
1998+
{ state: mockInitialState },
1999+
);
2000+
2001+
// Banner NOT rendered - 1 aggregator is not enough
2002+
expect(queryByTestId('perps-discovery-banner')).toBeNull();
2003+
});
2004+
});
18542005
});
18552006

18562007
describe('getSwapTokens', () => {

app/components/UI/AssetOverview/AssetOverview.tsx

Lines changed: 22 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { selectPerpsEnabledFlag } from '../Perps';
114114
import { usePerpsMarketForAsset } from '../Perps/hooks/usePerpsMarketForAsset';
115115
import PerpsDiscoveryBanner from '../Perps/components/PerpsDiscoveryBanner';
116116
import { PerpsEventValues } from '../Perps/constants/eventNames';
117+
import { isTokenTrustworthyForPerps } from '../Perps/constants/perpsConfig';
117118
import DSText, {
118119
TextVariant,
119120
} from '../../../component-library/components/Texts/Text';
@@ -312,6 +313,9 @@ const AssetOverview: React.FC<AssetOverviewProps> = ({
312313
isPerpsEnabled ? asset.symbol : null,
313314
);
314315

316+
// Check if token is trustworthy for showing Perps banner
317+
const isTokenTrustworthy = isTokenTrustworthyForPerps(asset);
318+
315319
const { styles } = useStyles(styleSheet, {});
316320
const dispatch = useDispatch();
317321

@@ -857,21 +861,24 @@ const AssetOverview: React.FC<AssetOverviewProps> = ({
857861
<MerklRewards asset={asset} />
858862
</View>
859863
)}
860-
{isPerpsEnabled && hasPerpsMarket && marketData && (
861-
<>
862-
<View style={styles.perpsPositionHeader}>
863-
<DSText variant={TextVariant.HeadingMD}>
864-
{strings('asset_overview.perps_position')}
865-
</DSText>
866-
</View>
867-
<PerpsDiscoveryBanner
868-
symbol={marketData.symbol}
869-
maxLeverage={marketData.maxLeverage}
870-
onPress={handlePerpsDiscoveryPress}
871-
testID="perps-discovery-banner"
872-
/>
873-
</>
874-
)}
864+
{isPerpsEnabled &&
865+
hasPerpsMarket &&
866+
marketData &&
867+
isTokenTrustworthy && (
868+
<>
869+
<View style={styles.perpsPositionHeader}>
870+
<DSText variant={TextVariant.HeadingMD}>
871+
{strings('asset_overview.perps_position')}
872+
</DSText>
873+
</View>
874+
<PerpsDiscoveryBanner
875+
symbol={marketData.symbol}
876+
maxLeverage={marketData.maxLeverage}
877+
onPress={handlePerpsDiscoveryPress}
878+
testID="perps-discovery-banner"
879+
/>
880+
</>
881+
)}
875882
<View style={styles.tokenDetailsWrapper}>
876883
<TokenDetails asset={asset} />
877884
</View>

app/components/UI/AssetOverview/Balance/Balance.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,8 +202,9 @@ const Balance = ({
202202
navigation.navigate('AssetDetails', {
203203
chainId: asset.chainId,
204204
address: asset.address,
205+
asset,
205206
}),
206-
[asset.address, asset.chainId, asset.isNative, navigation],
207+
[asset, navigation],
207208
);
208209

209210
const label = asset.accountType
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import {
2+
PERPS_MIN_AGGREGATORS_FOR_TRUST,
3+
isTokenTrustworthyForPerps,
4+
} from './perpsConfig';
5+
6+
describe('isTokenTrustworthyForPerps', () => {
7+
describe('native assets', () => {
8+
it('returns true for native asset (isNative: true)', () => {
9+
const asset = {
10+
isNative: true,
11+
isETH: false,
12+
aggregators: [],
13+
};
14+
15+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
16+
});
17+
18+
it('returns true for ETH asset (isETH: true)', () => {
19+
const asset = {
20+
isNative: false,
21+
isETH: true,
22+
aggregators: [],
23+
};
24+
25+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
26+
});
27+
28+
it('returns true for native asset even with no aggregators', () => {
29+
const asset = {
30+
isNative: true,
31+
aggregators: [],
32+
};
33+
34+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
35+
});
36+
});
37+
38+
describe('non-native assets with aggregators', () => {
39+
it('returns true when aggregators count equals minimum threshold', () => {
40+
const asset = {
41+
isNative: false,
42+
isETH: false,
43+
aggregators: Array(PERPS_MIN_AGGREGATORS_FOR_TRUST).fill('exchange'),
44+
};
45+
46+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
47+
});
48+
49+
it('returns true when aggregators count exceeds minimum threshold', () => {
50+
const asset = {
51+
isNative: false,
52+
isETH: false,
53+
aggregators: ['CoinGecko', 'CoinMarketCap', 'Uniswap'],
54+
};
55+
56+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
57+
});
58+
59+
it('returns false when aggregators count is below minimum threshold', () => {
60+
const asset = {
61+
isNative: false,
62+
isETH: false,
63+
aggregators: ['CoinGecko'], // Only 1
64+
};
65+
66+
expect(isTokenTrustworthyForPerps(asset)).toBe(false);
67+
});
68+
69+
it('returns false when aggregators is empty', () => {
70+
const asset = {
71+
isNative: false,
72+
isETH: false,
73+
aggregators: [],
74+
};
75+
76+
expect(isTokenTrustworthyForPerps(asset)).toBe(false);
77+
});
78+
});
79+
80+
describe('edge cases', () => {
81+
it('handles undefined aggregators', () => {
82+
const asset = {
83+
isNative: false,
84+
isETH: false,
85+
aggregators: undefined,
86+
};
87+
88+
expect(isTokenTrustworthyForPerps(asset)).toBe(false);
89+
});
90+
91+
it('handles missing properties', () => {
92+
const asset = {};
93+
94+
expect(isTokenTrustworthyForPerps(asset)).toBe(false);
95+
});
96+
97+
it('handles asset with only aggregators property', () => {
98+
const asset = {
99+
aggregators: ['CoinGecko', 'CoinMarketCap'],
100+
};
101+
102+
expect(isTokenTrustworthyForPerps(asset)).toBe(true);
103+
});
104+
});
105+
});
106+
107+
describe('PERPS_MIN_AGGREGATORS_FOR_TRUST', () => {
108+
it('is defined and is a number', () => {
109+
expect(typeof PERPS_MIN_AGGREGATORS_FOR_TRUST).toBe('number');
110+
});
111+
112+
it('equals 2', () => {
113+
expect(PERPS_MIN_AGGREGATORS_FOR_TRUST).toBe(2);
114+
});
115+
});

app/components/UI/Perps/constants/perpsConfig.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { TokenI } from '../../Tokens/types';
2+
13
/**
24
* Perps feature constants
35
*/
@@ -64,6 +66,29 @@ export const METAMASK_FEE_CONFIG = {
6466
// which returns complete fee breakdown including MetaMask fees
6567
} as const;
6668

69+
/**
70+
* Minimum number of aggregators (exchanges) a token must be listed on
71+
* to be considered trustworthy for showing the Perps Discovery Banner.
72+
* Native tokens (ETH, BNB, etc.) bypass this check.
73+
*/
74+
export const PERPS_MIN_AGGREGATORS_FOR_TRUST = 2;
75+
76+
/**
77+
* Checks if an asset is trustworthy for displaying the Perps Discovery Banner.
78+
* An asset is considered trustworthy if:
79+
* - It is a native asset (ETH, BNB, SOL, etc.), OR
80+
* - It is listed on at least PERPS_MIN_AGGREGATORS_FOR_TRUST exchanges
81+
*
82+
* @param asset - Asset object (TokenI or partial TokenI)
83+
* @returns true if the asset is trustworthy, false otherwise
84+
*/
85+
export const isTokenTrustworthyForPerps = (asset: Partial<TokenI>): boolean => {
86+
const isNativeAsset = asset.isNative || asset.isETH;
87+
const hasEnoughAggregators =
88+
(asset.aggregators?.length ?? 0) >= PERPS_MIN_AGGREGATORS_FOR_TRUST;
89+
return isNativeAsset || hasEnoughAggregators;
90+
};
91+
6792
/**
6893
* Validation thresholds for UI warnings and checks
6994
* These values control when warnings are shown to users

0 commit comments

Comments
 (0)