From fdb5dacbdc81bb9994f1dbdf0b1fa9d7fdc6db0b Mon Sep 17 00:00:00 2001 From: GustavoRSSilva Date: Mon, 24 Feb 2025 14:14:15 +0000 Subject: [PATCH 1/2] mms-1933: Solana Alerts --- app/_locales/en/messages.json | 24 +++++ .../security-alerts-api.util.test.ts | 41 +++++++ shared/types/security-alerts-api.ts | 100 ++++++++++++++++++ ui/ducks/bridge/selectors.ts | 2 +- ui/hooks/bridge/useSolanaTokenAlerts.test.ts | 61 +++++++++++ ui/hooks/bridge/useSolanaTokenAlerts.ts | 52 +++++++++ .../bridge/prepare/prepare-bridge-page.tsx | 29 +++++ 7 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 shared/modules/bridge-utils/security-alerts-api.util.test.ts create mode 100644 shared/types/security-alerts-api.ts create mode 100644 ui/hooks/bridge/useSolanaTokenAlerts.test.ts create mode 100644 ui/hooks/bridge/useSolanaTokenAlerts.ts diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0eb802250fbb..06c0f6f19e3c 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -323,6 +323,12 @@ "message": "I agree to MetaMask's $1", "description": "$1 is the `terms` link" }, + "airDropPatternDescription": { + "message": "The token's on-chain history reveals prior instances of suspicious airdrop activities." + }, + "airDropPatternTitle": { + "message": "Airdrop Pattern" + }, "airgapVault": { "message": "AirGap Vault" }, @@ -2227,6 +2233,12 @@ "holdToRevealUnlockedLabel": { "message": "hold to reveal circle unlocked" }, + "honeypotDescription": { + "message": "This token might pose a honeypot risk. It is advised to conduct due diligence before interacting to prevent any potential financial loss." + }, + "honeypotTitle": { + "message": "Honey Pot" + }, "howNetworkFeesWorkExplanation": { "message": "Estimated fee required to process the transaction. The max fee is $1." }, @@ -2360,6 +2372,12 @@ "insufficientFundsForGas": { "message": "Insufficient funds for gas" }, + "insufficientLockedLiquidityDescription": { + "message": "The lack of adequately locked or burned liquidity leaves the token vulnerable to sudden liquidity withdrawals, potentially causing market instability." + }, + "insufficientLockedLiquidityTitle": { + "message": "Insufficient Locked Liquidity" + }, "insufficientTokens": { "message": "Insufficient tokens." }, @@ -6089,6 +6107,12 @@ "message": "Sending NFT (ERC-721) tokens is not currently supported", "description": "This is an error message we show the user if they attempt to send an NFT asset type, for which currently don't support sending" }, + "unstableTokenPriceDescription": { + "message": "The price of this token in USD is highly volatile, indicating a high risk of losing significant value by interacting with it." + }, + "unstableTokenPriceTitle": { + "message": "Unstable Token Price" + }, "upArrow": { "message": "up arrow" }, diff --git a/shared/modules/bridge-utils/security-alerts-api.util.test.ts b/shared/modules/bridge-utils/security-alerts-api.util.test.ts new file mode 100644 index 000000000000..6d46b3cf8146 --- /dev/null +++ b/shared/modules/bridge-utils/security-alerts-api.util.test.ts @@ -0,0 +1,41 @@ +import { + TokenFeature, + TokenFeatureType, +} from '../../types/security-alerts-api'; +import { getTokenFeatureTitleDescriptionIds } from './security-alerts-api.util'; + +describe('Security alerts utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getTokenFeatureTitleDescriptionIds', () => { + it('Should correctly add title Id and Description Id', async () => { + const mockTokenAlert = { + type: TokenFeatureType.MALICIOUS, + feature_id: 'UNSTABLE_TOKEN_PRICE', + description: 'This token is Malicious', + }; + + const tokenAlertWithLabelIds = + getTokenFeatureTitleDescriptionIds(mockTokenAlert); + expect(tokenAlertWithLabelIds.titleId).toBeTruthy(); + expect(tokenAlertWithLabelIds.descriptionId).toBeTruthy(); + }); + + it('Should correctly return title Id and Description Id null if not available', async () => { + const mockTokenAlert = { + type: TokenFeatureType.BENIGN, + feature_id: 'BENIGN_TYPE', + description: 'This token is Benign', + }; + + const tokenAlertWithLabelIds = + getTokenFeatureTitleDescriptionIds(mockTokenAlert); + expect(tokenAlertWithLabelIds.titleId).toBeNull(); + expect(tokenAlertWithLabelIds.descriptionId).toBeNull(); + }); + }); + + +}); diff --git a/shared/types/security-alerts-api.ts b/shared/types/security-alerts-api.ts new file mode 100644 index 000000000000..e165f6cb9995 --- /dev/null +++ b/shared/types/security-alerts-api.ts @@ -0,0 +1,100 @@ +export type ScanTokenRequest = { + chain: string; + address: string; + metadata: { + domain: string; + }; +}; + +type TokenMetadata = { + type: string; + name: string; + symbol: string; + image_url: string; + description: string; + deployer: string; + deployer_balance: { + amount: number | null; + amount_wei: number | null; + }; + contract_balance: { + amount: number; + amount_wei: string | null; + } | null; + owner_balance: { + amount: number; + amount_wei: string | null; + } | null; + owner: string | null; + creation_timestamp: number | null; + external_links: unknown; + urls: string[]; + malicious_urls: string[]; + token_creation_initiator: string | null; + mint_authority: string | null; + update_authority: string | null; + freeze_authority: string | null; +}; + +type EvmTokenMetadata = TokenMetadata & { + decimals: number; +}; + +export enum TokenFeatureType { + MALICIOUS = 'Malicious', + WARNING = 'Warning', + INFO = 'Info', + BENIGN = 'Benign', +} + +export type TokenFeature = { + feature_id: string; + type: TokenFeatureType; + description: string; +}; + +export type ScanTokenResponse = { + result_type: string; + malicious_score: string; + attack_types: unknown; + chain: string; + address: string; + metadata: TokenMetadata | EvmTokenMetadata; + fees: { + transfer: number | null; + buy: number | null; + sell: number | null; + }; + features: TokenFeature[]; + trading_limits: { + max_buy: { + amount: number; + amount_wei: string; + } | null; + max_sell: { + amount: number; + amount_wei: string; + } | null; + max_holding: { + amount: number | null; + amount_wei: number | null; + } | null; + sell_limit_per_block: number | null; + }; + financial_stats: { + supply: number | null; + holders_count: number | null; + usd_price_per_unit: number | null; + burned_liquidity_percentage: number | null; + locked_liquidity_percentage: number | null; + top_holders: { + address: string; + holding_percentage: number | null; + }[]; + }; +}; + +export type TokenAlertWithLabelIds = TokenFeature & { + titleId: string | null; + descriptionId: string | null; +}; diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts index 1178e39ccddf..0da58e1119b2 100644 --- a/ui/ducks/bridge/selectors.ts +++ b/ui/ducks/bridge/selectors.ts @@ -20,7 +20,6 @@ import { BRIDGE_PREFERRED_GAS_ESTIMATE, BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE, } from '../../../shared/constants/bridge'; -import type { BridgeControllerState } from '../../../shared/types/bridge'; import { createDeepEqualSelector } from '../../../shared/modules/selectors/util'; import { SWAPS_CHAINID_DEFAULT_TOKEN_MAP } from '../../../shared/constants/swaps'; import { @@ -33,6 +32,7 @@ import { type BridgeToken, type QuoteMetadata, type QuoteResponse, + type BridgeControllerState, SortOrder, BridgeFeatureFlagsKey, RequestStatus, diff --git a/ui/hooks/bridge/useSolanaTokenAlerts.test.ts b/ui/hooks/bridge/useSolanaTokenAlerts.test.ts new file mode 100644 index 000000000000..f101979001ee --- /dev/null +++ b/ui/hooks/bridge/useSolanaTokenAlerts.test.ts @@ -0,0 +1,61 @@ +import { renderHookWithProvider } from '../../../test/lib/render-helpers'; +import { CHAIN_IDS } from '../../../shared/constants/network'; +import useSolanaTokenAlerts from './useSolanaTokenAlerts'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; +import { createBridgeMockStore } from '../../../test/jest/mock-store'; + +const SECURITY_API_URL = 'https://test.com'; + +const renderUseSolanaAlerts = (mockStoreState: object) => + renderHookWithProvider(() => useSolanaTokenAlerts(), mockStoreState); + +const mockResponse = { + type: 'warning', + feature_id: 'UNSTABLE_TOKEN_PRICE', + description: + 'The price of this token in USD is highly volatile, indicating a high risk of losing significant value by interacting with it.', + titleId: 'unstableTokenPriceTitle', + descriptionId: 'unstableTokenPriceDescription', +}; +jest.mock( + '../../../shared/modules/bridge-utils/security-alerts-api.util', + () => ({ + ...jest.requireActual( + '../../../shared/modules/bridge-utils/security-alerts-api.util', + ), + fetchTokenAlert: () => mockResponse, + }), +); + +// For now we have to mock toChain Solana, remove once it is truly implemented +const mockGetToChain = { chainId: MultichainNetworks.SOLANA }; +jest.mock('../../ducks/bridge/selectors', () => ({ + ...jest.requireActual('../../ducks/bridge/selectors'), + getToChain: () => mockGetToChain, +})); + +describe('useSolanaTokenAlerts', () => { + it('should set token alert when toChain is Solana', async () => { + const mockStoreState = createBridgeMockStore({ + bridgeSliceOverrides: { + fromToken: { + address: '0x3fa807b6f8d4c407e6e605368f4372d14658b38c', + }, + fromChain: { + chainId: CHAIN_IDS.MAINNET, + }, + toToken: { + address: '6p6xgHyF7AeE6TZkSmFsko444wqoP15icUSqi2jfGiPN', + }, + toChain: { + chainId: MultichainNetworks.SOLANA, + }, + }, + }); + + const { result, waitForNextUpdate } = renderUseSolanaAlerts(mockStoreState); + await waitForNextUpdate(); + + expect(result.current.tokenAlert).toBeTruthy(); + }); +}); diff --git a/ui/hooks/bridge/useSolanaTokenAlerts.ts b/ui/hooks/bridge/useSolanaTokenAlerts.ts new file mode 100644 index 000000000000..3a354bf2c429 --- /dev/null +++ b/ui/hooks/bridge/useSolanaTokenAlerts.ts @@ -0,0 +1,52 @@ +import { useSelector } from 'react-redux'; +import { useEffect, useState } from 'react'; +import { + getFromToken, + getFromChain, + getToToken, + getToChain, +} from '../../ducks/bridge/selectors'; +import { MultichainNetworks } from '../../../shared/constants/multichain/networks'; +import { fetchTokenAlert } from '../../../shared/modules/bridge-utils/security-alerts-api.util'; +import { TokenAlertWithLabelIds } from '../../../shared/types/security-alerts-api'; + +export const useSolanaTokenAlerts = () => { + const [tokenAlert, setTokenAlert] = useState( + null, + ); + + const fromToken = useSelector(getFromToken); + const fromChain = useSelector(getFromChain); + const toToken = useSelector(getToToken); + const toChain = useSelector(getToChain); + + useEffect(() => { + async function fetchData() { + // At the moment we only support Solana Chain + if ( + !fromToken || + !fromChain || + !toToken || + !toChain || + (toChain?.chainId as string) !== MultichainNetworks.SOLANA + ) { + return; + } + + const chainName = 'solana'; + + const tAlert = await fetchTokenAlert(chainName, toToken.address); + + if (tAlert) { + setTokenAlert(tAlert); + } + } + + fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [toToken, toChain]); + + return { tokenAlert }; +}; + +export default useSolanaTokenAlerts; diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx index afc6ad6886c1..bb3b8ff8e8e3 100644 --- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx +++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx @@ -93,6 +93,8 @@ import { SECOND } from '../../../../shared/constants/time'; import { BRIDGE_QUOTE_MAX_RETURN_DIFFERENCE_PERCENTAGE } from '../../../../shared/constants/bridge'; import { getIntlLocale } from '../../../ducks/locale/locale'; import { useIsMultichainSwap } from '../hooks/useIsMultichainSwap'; +import { useSolanaTokenAlerts } from '../../../hooks/bridge/useSolanaTokenAlerts'; +import { TokenFeatureType } from '../../../../shared/types/security-alerts-api'; import { BridgeInputGroup } from './bridge-input-group'; import { BridgeCTAButton } from './bridge-cta-button'; @@ -168,6 +170,8 @@ const PrepareBridgePage = () => { fromChain?.chainId, ); + const { tokenAlert } = useSolanaTokenAlerts(); + const { filteredTokenListGenerator: toTokenListGenerator, isLoading: isToTokensLoading, @@ -186,6 +190,10 @@ const PrepareBridgePage = () => { const [isLowReturnBannerOpen, setIsLowReturnBannerOpen] = useState(true); useEffect(() => setIsLowReturnBannerOpen(true), [quotesRefreshCount]); + // Resets the banner visibility when new alerts found + const [isTokenAlertBannerOpen, setIsTokenAlertBannerOpen] = useState(true); + useEffect(() => setIsLowReturnBannerOpen(true), [tokenAlert]); + // Background updates are debounced when the switch button is clicked // To prevent putting the frontend in an unexpected state, prevent the user // from switching tokens within the debounce period @@ -240,6 +248,7 @@ const PrepareBridgePage = () => { // Scroll to bottom of the page when banners are shown const insufficientBalanceBannerRef = useRef(null); const isEstimatedReturnLowRef = useRef(null); + const tokenAlertBannerRef = useRef(null); useEffect(() => { if (isInsufficientGasForQuote(nativeAssetBalance)) { insufficientBalanceBannerRef.current?.scrollIntoView({ @@ -666,6 +675,26 @@ const PrepareBridgePage = () => { onClose={() => setIsLowReturnBannerOpen(false)} /> )} + {tokenAlert && isTokenAlertBannerOpen && ( + setIsTokenAlertBannerOpen(false)} + /> + )} {!isLoading && activeQuote && !isInsufficientBalance(srcTokenBalance) && From 31e6a3cc793a38a3add1bf23b3b276da9755288f Mon Sep 17 00:00:00 2001 From: GustavoRSSilva Date: Mon, 24 Feb 2025 16:23:22 +0000 Subject: [PATCH 2/2] mms-1933: Solana Alerts --- .../bridge-utils/security-alerts-api.util.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 shared/modules/bridge-utils/security-alerts-api.util.ts diff --git a/shared/modules/bridge-utils/security-alerts-api.util.ts b/shared/modules/bridge-utils/security-alerts-api.util.ts new file mode 100644 index 000000000000..59a99aeb12d2 --- /dev/null +++ b/shared/modules/bridge-utils/security-alerts-api.util.ts @@ -0,0 +1,120 @@ +import { + ScanTokenRequest, + TokenFeature, + TokenFeatureType, + TokenAlertWithLabelIds, +} from '../../types/security-alerts-api'; + +const DOMAIN = 'https://metamask.io'; + +export function isSecurityAlertsAPIEnabled() { + const isEnabled = process.env.SECURITY_ALERTS_API_ENABLED; + return isEnabled?.toString() === 'true'; +} + +function getUrl(endpoint: string) { + const host = process.env.SECURITY_ALERTS_API_URL; + + if (!host) { + throw new Error('Security alerts API URL is not set'); + } + + return `${host}/${endpoint}`; +} + +function getSecurityApiScanTokenRequestBody( + chain: string, + address: string, +): ScanTokenRequest { + return { + chain, + address, + metadata: { + domain: DOMAIN, + }, + }; +} + +/** + * Given a list of TokenFeatures, return the first TokenFeature that is the type Malicious, if not try for Warning, if not return null + * + * @param features + * @returns TokenFeature + */ +function getFirstTokenAlert(features: TokenFeature[]): TokenFeature | null { + return ( + features.find((feature) => feature.type === TokenFeatureType.MALICIOUS) || + features.find((feature) => feature.type === TokenFeatureType.WARNING) || + null + ); +} + +export async function fetchTokenAlert( + chain: string, + tokenAddress: string, +): Promise { + if (!isSecurityAlertsAPIEnabled()) { + return null; + } + + const url = getUrl('token/scan'); + const body = getSecurityApiScanTokenRequestBody(chain, tokenAddress); + + const response = await fetch(url, { + method: 'POST', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error( + `Security alerts API request failed with status: ${response.status}`, + ); + } + + const respBody = await response.json(); + + const tokenAlert = getFirstTokenAlert(respBody.features); + + if (!tokenAlert) { + return null; + } + + return getTokenFeatureTitleDescriptionIds(tokenAlert); +} + +export function getTokenFeatureTitleDescriptionIds( + tokenFeature: TokenFeature, +): TokenAlertWithLabelIds { + let titleId = null; + let descriptionId = null; + + switch (tokenFeature.feature_id) { + case 'UNSTABLE_TOKEN_PRICE': + titleId = 'unstableTokenPriceTitle'; + descriptionId = 'unstableTokenPriceDescription'; + break; + case 'HONEYPOT': + titleId = 'honeypotTitle'; + descriptionId = 'honeypotDescription'; + break; + case 'INSUFFICIENT_LOCKED_LIQUIDITY': + titleId = 'insufficientLockedLiquidityTitle'; + descriptionId = 'insufficientLockedLiquidityDescription'; + break; + case 'AIRDROP_PATTERN': + titleId = 'airDropPatternTitle'; + descriptionId = 'airDropPatternDescription'; + break; + + default: + console.warn( + `Missing token alert translation for ${tokenFeature.feature_id}.`, + tokenFeature.description, + ); + } + + return { ...tokenFeature, titleId, descriptionId }; +}