Skip to content

Commit e717be3

Browse files
feat: track token_security_type_destination in metrics (MetaMask#29381)
<!-- 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? --> The `Unified SwapBridge Submitted` and `Unified SwapBridge Completed` analytics events were missing the destination token's security classification, so we could not measure how often users proceed with risky tokens. They also had no source/destination token addresses on the `Submitted` event for cross-event joins. This PR threads the destination token's security type end-to-end into the unified-swap analytics events, including from the trending-tokens entry point (which uses a different `securityData` shape). **Changes:** 1. **Bump bridge controllers** to pick up the new `tokenSecurityTypeDestination` parameter on `BridgeStatusController.submitTx` / `submitIntent` and the `token_address_source` / `token_address_destination` / `token_security_type_destination` properties on the pre-confirmation event payload. - `@metamask/bridge-controller`: `^70.0.0` → `^71.0.0` - `@metamask/bridge-status-controller`: `^71.0.0` → `^71.1.0` 2. **Surface the field for analytics** in `useUnifiedSwapBridgeContext` (`token_security_type_destination`, `security_warnings`) and pass it through `useSubmitBridgeTx` to both `submitTx` and `submitIntent`. 3. **Close the trending-token data gap.** Trending tokens carry `securityData` in the trending-API shape (`TokenSecurityData`: `resultType` + top-level `features`), which doesn't match the bridge's legacy `SecurityData` shape (`type` + `metadata.features`). Two pieces: - Added `securityData?: TokenSecurityData` to `TokenI` so the read at the Token Details → Bridge boundary is type-safe. - Added `adaptTokenSecurityData()` in `tokenSecurityUtils` and applied it at the only two boundary sites in `useTokenActions` (`getSwapTokens` and `currentTokenAsBridgeToken`) so all downstream Bridge consumers continue reading the legacy shape they already understand. No widening of `BridgeToken.securityData` was required. **Bonus side-effect of step 3:** `SwapsConfirmButton`'s warning banner and modal now also render correctly for trending-token destinations classified `Warning` / `Malicious` / `Spam`, since they reach `destToken.securityData` in the shape the banner already reads. ## **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: null ## **Related issues** Fixes: [SWAPS-4422](https://consensyssoftware.atlassian.net/browse/SWAPS-4422) ## **Manual testing steps** ```gherkin Feature: Destination token security type reaches unified-swap analytics Scenario: user swaps from a trending token classified as Warning/Malicious/Spam Given the user has opened the Bridge view And the trending tokens section is visible And one of the trending tokens carries securityData with resultType "Warning" When the user taps that trending token And the user taps "Convert" / "Swap" on the Token Details screen And the user enters a source amount and confirms the swap Then the SwapsConfirmButton renders the security warning banner before confirmation And the "Unified SwapBridge Submitted" Mixpanel event includes: | property | value | | token_security_type_destination | Warning | | token_address_source | <CAIP source asset id> | | token_address_destination | <CAIP destination asset id> | | security_warnings | <list of feature descriptions> | And the "Unified SwapBridge Completed" event includes the same security_type_destination Scenario: user swaps to a destination token with no security data Given the user has opened the Bridge view And the user picks any source and destination token without securityData When the user enters a source amount and confirms the swap Then the "Unified SwapBridge Submitted" event property token_security_type_destination is null ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** N/A ### **After** https://github.com/user-attachments/assets/e93b9542-603d-4faa-9f14-f36cb199484b ## **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. --> - [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. #### 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. <!-- Generated with the help of the pr-description AI skill --> [SWAPS-4422]: https://consensyssoftware.atlassian.net/browse/SWAPS-4422?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Touches swap/bridge transaction submission plumbing and controller interfaces (plus dependency bumps), so incorrect threading/mapping could silently skew analytics or break submissions on certain paths. > > **Overview** > Adds `token_security_type_destination` to the `useUnifiedSwapBridgeContext` analytics payload and threads the same value (`tokenSecurityTypeDestination`) through `useSubmitBridgeTx` into `BridgeStatusController.submitTx`/`submitIntent`. > > Introduces `adaptTokenSecurityData()` to convert trending-token `TokenSecurityData` into the bridge’s legacy `securityData` shape, uses it when converting `TokenI` to `BridgeToken` in Token Details swap entry points, and updates types/tests plus initial background state accordingly. Also bumps `@metamask/bridge-controller` and `@metamask/bridge-status-controller` to versions that accept/emit these new analytics fields. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c6f918c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 7fded83 commit e717be3

12 files changed

Lines changed: 346 additions & 14 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export const useUnifiedSwapBridgeContext = () => {
6262
stx_enabled: smartTransactionsEnabled,
6363
token_symbol_source: fromToken?.symbol ?? '',
6464
token_symbol_destination: toToken?.symbol ?? '',
65+
token_security_type_destination: toToken?.securityData?.type ?? null,
6566
security_warnings: getSecurityWarnings(toToken),
6667
warnings: [], // TODO
6768
usd_amount_source: usdAmountSource,

app/components/UI/Bridge/hooks/useUnifiedSwapBridgeContext/useUnifiedSwapBridgeContext.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('useUnifiedSwapBridgeContext', () => {
9191
stx_enabled: true,
9292
token_symbol_source: 'ETH',
9393
token_symbol_destination: 'USDC',
94+
token_security_type_destination: null,
9495
security_warnings: [],
9596
warnings: [],
9697
usd_amount_source: 0,
@@ -130,6 +131,7 @@ describe('useUnifiedSwapBridgeContext', () => {
130131
'Honeypot risk detected',
131132
'Concentrated supply risk',
132133
]);
134+
expect(result.current.token_security_type_destination).toBe('Warning');
133135
});
134136

135137
it('returns empty security_warnings when source token has warnings but destination does not', () => {
@@ -153,6 +155,7 @@ describe('useUnifiedSwapBridgeContext', () => {
153155
);
154156

155157
expect(result.current.security_warnings).toEqual([]);
158+
expect(result.current.token_security_type_destination).toBeNull();
156159
});
157160

158161
it('returns empty security_warnings when destination token has no securityData', () => {
@@ -166,6 +169,7 @@ describe('useUnifiedSwapBridgeContext', () => {
166169
);
167170

168171
expect(result.current.security_warnings).toEqual([]);
172+
expect(result.current.token_security_type_destination).toBeNull();
169173
});
170174

171175
it('returns empty token symbols when tokens are undefined', () => {
@@ -191,6 +195,7 @@ describe('useUnifiedSwapBridgeContext', () => {
191195
stx_enabled: false,
192196
token_symbol_source: '',
193197
token_symbol_destination: '',
198+
token_security_type_destination: null,
194199
security_warnings: [],
195200
warnings: [],
196201
usd_amount_source: 0,

app/components/UI/Bridge/utils/tokenSecurityUtils.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
import { TokenSecurityData } from '@metamask/assets-controllers';
12
import { IconColor, IconName } from '@metamask/design-system-react-native';
23
import { TagSeverity } from '../../../../component-library/base-components/TagBase';
34
import { strings } from '../../../../../locales/i18n';
45
import { SecurityDataType } from '../hooks/usePopularTokens';
56
import { createMockToken } from '../testUtils/fixtures';
67
import {
8+
adaptTokenSecurityData,
79
getBridgeTokenSecurityConfig,
810
getSecurityWarnings,
911
isNegativeSecurityType,
@@ -118,4 +120,99 @@ describe('tokenSecurityUtils', () => {
118120
expect(config.severity).toBe(TagSeverity.Default);
119121
});
120122
});
123+
124+
describe('adaptTokenSecurityData', () => {
125+
const buildTrendingShape = (
126+
overrides: Partial<TokenSecurityData> = {},
127+
): TokenSecurityData => ({
128+
resultType: 'Verified',
129+
maliciousScore: '0',
130+
fees: { transfer: 0, transferFeeMaxAmount: null, buy: 0, sell: null },
131+
features: [],
132+
financialStats: {
133+
supply: 0,
134+
topHolders: [],
135+
holdersCount: 0,
136+
tradeVolume24h: null,
137+
lockedLiquidityPct: null,
138+
markets: [],
139+
},
140+
metadata: {
141+
externalLinks: {
142+
homepage: null,
143+
twitterPage: null,
144+
telegramChannelId: null,
145+
},
146+
},
147+
created: '2025-01-01T00:00:00Z',
148+
...overrides,
149+
});
150+
151+
it('returns undefined when input is undefined', () => {
152+
expect(adaptTokenSecurityData(undefined)).toBeUndefined();
153+
});
154+
155+
it.each([
156+
SecurityDataType.Verified,
157+
SecurityDataType.Benign,
158+
SecurityDataType.Warning,
159+
SecurityDataType.Spam,
160+
SecurityDataType.Malicious,
161+
SecurityDataType.Info,
162+
])('maps resultType %s to type', (value) => {
163+
const adapted = adaptTokenSecurityData(
164+
buildTrendingShape({ resultType: value }),
165+
);
166+
167+
expect(adapted?.type).toBe(value);
168+
});
169+
170+
it('passes through unknown resultType strings as-is', () => {
171+
const adapted = adaptTokenSecurityData(
172+
buildTrendingShape({ resultType: 'SomethingNew' }),
173+
);
174+
175+
expect(adapted?.type).toBe('SomethingNew');
176+
});
177+
178+
it('maps top-level features to metadata.features preserving fields', () => {
179+
const adapted = adaptTokenSecurityData(
180+
buildTrendingShape({
181+
features: [
182+
{
183+
featureId: 'HONEYPOT',
184+
type: 'Warning',
185+
description: 'Honeypot risk',
186+
},
187+
{
188+
featureId: 'RUGPULL',
189+
type: 'Malicious',
190+
description: 'Rugpull risk',
191+
},
192+
],
193+
}),
194+
);
195+
196+
expect(adapted?.metadata?.features).toEqual([
197+
{
198+
featureId: 'HONEYPOT',
199+
type: 'Warning',
200+
description: 'Honeypot risk',
201+
},
202+
{
203+
featureId: 'RUGPULL',
204+
type: 'Malicious',
205+
description: 'Rugpull risk',
206+
},
207+
]);
208+
});
209+
210+
it('returns metadata.features as empty array when input has no features', () => {
211+
const adapted = adaptTokenSecurityData(
212+
buildTrendingShape({ features: [] }),
213+
);
214+
215+
expect(adapted?.metadata?.features).toEqual([]);
216+
});
217+
});
121218
});

app/components/UI/Bridge/utils/tokenSecurityUtils.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import { TokenSecurityData } from '@metamask/assets-controllers';
12
import { IconColor, IconName } from '@metamask/design-system-react-native';
23
import { TagSeverity } from '../../../../component-library/base-components/TagBase';
34
import { strings } from '../../../../../locales/i18n';
45
import { getResultTypeConfig } from '../../SecurityTrust/utils/securityUtils';
5-
import { SecurityDataType } from '../hooks/usePopularTokens';
6+
import {
7+
SecurityData,
8+
SecurityDataType,
9+
SecurityFeature,
10+
} from '../hooks/usePopularTokens';
611
import { BridgeToken } from '../types';
712

813
/**
@@ -14,6 +19,31 @@ export const getSecurityWarnings = (
1419
): string[] =>
1520
token?.securityData?.metadata?.features?.map((f) => f.description) ?? [];
1621

22+
/**
23+
* Adapts the trending-API security shape (`TokenSecurityData`) to the bridge's
24+
* legacy `SecurityData` shape consumed by Bridge UI and analytics.
25+
*
26+
* Returns `undefined` if the input is missing. Unknown `resultType` strings are
27+
* passed through cast as `SecurityDataType`; downstream consumers already
28+
* tolerate unrecognized values via default branches in
29+
* `getBridgeTokenSecurityConfig`, `isNegativeSecurityType`, etc.
30+
*/
31+
export const adaptTokenSecurityData = (
32+
data: TokenSecurityData | undefined,
33+
): SecurityData | undefined => {
34+
if (!data) return undefined;
35+
return {
36+
type: data.resultType as SecurityDataType,
37+
metadata: {
38+
features: (data.features ?? []).map((f) => ({
39+
featureId: f.featureId,
40+
type: f.type as SecurityDataType,
41+
description: f.description,
42+
})) as SecurityFeature[],
43+
},
44+
};
45+
};
46+
1747
export interface BridgeTokenSecurityConfig {
1848
iconName: IconName;
1949
iconColor: IconColor;

app/components/UI/TokenDetails/hooks/useTokenActions.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { renderHook } from '@testing-library/react-native';
2+
import { TokenSecurityData } from '@metamask/assets-controllers';
23
import { useSelector } from 'react-redux';
34
import { useTokenActions, getSwapTokens } from './useTokenActions';
45
import { TokenI } from '../../Tokens/types';
6+
import { SecurityDataType } from '../../Bridge/hooks/usePopularTokens';
57
import { selectEvmChainId } from '../../../../selectors/networkController';
68
import { selectSelectedInternalAccount } from '../../../../selectors/accountsController';
79
import { selectSelectedAccountGroup } from '../../../../selectors/multichainAccounts/accountTreeController';
@@ -716,5 +718,105 @@ describe('useTokenActions', () => {
716718
);
717719
},
718720
);
721+
722+
describe('securityData adaptation', () => {
723+
const buildTrendingSecurityData = (
724+
overrides: Partial<TokenSecurityData> = {},
725+
): TokenSecurityData => ({
726+
resultType: 'Warning',
727+
maliciousScore: '50',
728+
fees: { transfer: 0, transferFeeMaxAmount: null, buy: 0, sell: null },
729+
features: [
730+
{
731+
featureId: 'HONEYPOT',
732+
type: 'Warning',
733+
description: 'Honeypot risk',
734+
},
735+
],
736+
financialStats: {
737+
supply: 0,
738+
topHolders: [],
739+
holdersCount: 0,
740+
tradeVolume24h: null,
741+
lockedLiquidityPct: null,
742+
markets: [],
743+
},
744+
metadata: {
745+
externalLinks: {
746+
homepage: null,
747+
twitterPage: null,
748+
telegramChannelId: null,
749+
},
750+
},
751+
created: '2025-01-01T00:00:00Z',
752+
...overrides,
753+
});
754+
755+
it("adapts trending-shape securityData to the bridge's legacy shape when handing off to goToSwaps", () => {
756+
const tokenWithSecurity: TokenI = {
757+
...defaultToken,
758+
balance: '1',
759+
securityData: buildTrendingSecurityData(),
760+
} as TokenI;
761+
762+
const { result } = renderHook(() =>
763+
useTokenActions({
764+
token: tokenWithSecurity,
765+
networkName: 'Ethereum Mainnet',
766+
}),
767+
);
768+
769+
result.current.handleStickySwapPress();
770+
771+
expect(mockGoToSwaps).toHaveBeenCalledTimes(1);
772+
expect(mockGoToSwaps).toHaveBeenCalledWith(
773+
expect.objectContaining({
774+
address: defaultToken.address,
775+
securityData: {
776+
type: SecurityDataType.Warning,
777+
metadata: {
778+
features: [
779+
{
780+
featureId: 'HONEYPOT',
781+
type: SecurityDataType.Warning,
782+
description: 'Honeypot risk',
783+
},
784+
],
785+
},
786+
},
787+
}),
788+
undefined,
789+
undefined,
790+
true,
791+
);
792+
});
793+
794+
it('passes securityData as undefined when token has no security data', () => {
795+
const tokenWithBalance: TokenI = {
796+
...defaultToken,
797+
balance: '1',
798+
} as TokenI;
799+
800+
const { result } = renderHook(() =>
801+
useTokenActions({
802+
token: tokenWithBalance,
803+
networkName: 'Ethereum Mainnet',
804+
}),
805+
);
806+
807+
result.current.handleStickySwapPress();
808+
809+
expect(mockGoToSwaps).toHaveBeenCalledTimes(1);
810+
expect(mockGoToSwaps).toHaveBeenCalledWith(
811+
expect.objectContaining({
812+
address: defaultToken.address,
813+
securityData: undefined,
814+
}),
815+
undefined,
816+
undefined,
817+
true,
818+
);
819+
});
820+
});
719821
});
720822
});

app/components/UI/TokenDetails/hooks/useTokenActions.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@ import {
3535
} from '../../Bridge/utils/tokenUtils';
3636
import { useSendNonEvmAsset } from '../../../hooks/useSendNonEvmAsset';
3737
import {
38-
formatAddressToAssetId,
3938
formatChainIdToCaip,
4039
isNativeAddress,
4140
} from '@metamask/bridge-controller';
@@ -46,6 +45,7 @@ import { getDetectedGeolocation } from '../../../../reducers/fiatOrders';
4645
import { useRampsButtonClickData } from '../../Ramp/hooks/useRampsButtonClickData';
4746
import useRampsUnifiedV1Enabled from '../../Ramp/hooks/useRampsUnifiedV1Enabled';
4847
import { BridgeToken } from '../../Bridge/types';
48+
import { adaptTokenSecurityData } from '../../Bridge/utils/tokenSecurityUtils';
4949
import { TokenDetailsSource } from '../constants/constants';
5050
import type { TransactionActiveAbTestEntry } from '../../../../util/transactions/transaction-active-ab-test-attribution-registry';
5151

@@ -69,6 +69,7 @@ export const getSwapTokens = (
6969
symbol: token.symbol,
7070
name: token.name,
7171
image: token.image,
72+
securityData: adaptTokenSecurityData(token.securityData),
7273
};
7374

7475
if (wantsToBuyToken) {
@@ -342,6 +343,7 @@ export const useTokenActions = ({
342343
symbol: token.symbol,
343344
name: token.name,
344345
image: token.image,
346+
securityData: adaptTokenSecurityData(token.securityData),
345347
}),
346348
[
347349
token.address,
@@ -350,6 +352,7 @@ export const useTokenActions = ({
350352
token.symbol,
351353
token.name,
352354
token.image,
355+
token.securityData,
353356
],
354357
);
355358

app/components/UI/Tokens/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { KeyringAccountType } from '@metamask/keyring-api';
2-
import { TokenRwaData } from '@metamask/assets-controllers';
2+
import { TokenRwaData, TokenSecurityData } from '@metamask/assets-controllers';
33

44
export interface BrowserTab {
55
id: string;
@@ -26,4 +26,5 @@ export interface TokenI {
2626
accountType?: KeyringAccountType;
2727
pricePercentChange1d?: number;
2828
rwaData?: TokenRwaData;
29+
securityData?: TokenSecurityData;
2930
}

0 commit comments

Comments
 (0)