Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

mms-1933: Solana Alerts #30528

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions app/_locales/en/messages.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

41 changes: 41 additions & 0 deletions shared/modules/bridge-utils/security-alerts-api.util.test.ts
Original file line number Diff line number Diff line change
@@ -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 () => {

Check failure on line 13 in shared/modules/bridge-utils/security-alerts-api.util.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

`it`s should begin with lowercase
const mockTokenAlert = <TokenFeature>{

Check failure on line 14 in shared/modules/bridge-utils/security-alerts-api.util.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Use 'as TokenFeature' instead of '<TokenFeature>'
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 () => {

Check failure on line 26 in shared/modules/bridge-utils/security-alerts-api.util.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

`it`s should begin with lowercase
const mockTokenAlert = <TokenFeature>{

Check failure on line 27 in shared/modules/bridge-utils/security-alerts-api.util.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Use 'as TokenFeature' instead of '<TokenFeature>'
type: TokenFeatureType.BENIGN,
feature_id: 'BENIGN_TYPE',
description: 'This token is Benign',
};

const tokenAlertWithLabelIds =
getTokenFeatureTitleDescriptionIds(mockTokenAlert);
expect(tokenAlertWithLabelIds.titleId).toBeNull();
expect(tokenAlertWithLabelIds.descriptionId).toBeNull();
});
});

Check failure on line 39 in shared/modules/bridge-utils/security-alerts-api.util.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Delete `⏎··⏎`

});
120 changes: 120 additions & 0 deletions shared/modules/bridge-utils/security-alerts-api.util.ts
Original file line number Diff line number Diff line change
@@ -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<TokenAlertWithLabelIds | null> {
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 };
}
100 changes: 100 additions & 0 deletions shared/types/security-alerts-api.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 1 addition & 1 deletion ui/ducks/bridge/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +32,7 @@ import {
type BridgeToken,
type QuoteMetadata,
type QuoteResponse,
type BridgeControllerState,
SortOrder,
BridgeFeatureFlagsKey,
RequestStatus,
Expand Down
61 changes: 61 additions & 0 deletions ui/hooks/bridge/useSolanaTokenAlerts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { renderHookWithProvider } from '../../../test/lib/render-helpers';
import { CHAIN_IDS } from '../../../shared/constants/network';
import useSolanaTokenAlerts from './useSolanaTokenAlerts';

Check failure on line 3 in ui/hooks/bridge/useSolanaTokenAlerts.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

`./useSolanaTokenAlerts` import should occur after import of `../../../test/jest/mock-store`

Check failure on line 3 in ui/hooks/bridge/useSolanaTokenAlerts.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

Using exported name 'useSolanaTokenAlerts' as identifier for default export
import { MultichainNetworks } from '../../../shared/constants/multichain/networks';
import { createBridgeMockStore } from '../../../test/jest/mock-store';

const SECURITY_API_URL = 'https://test.com';

Check failure on line 7 in ui/hooks/bridge/useSolanaTokenAlerts.test.ts

View workflow job for this annotation

GitHub Actions / Test lint / Test lint

'SECURITY_API_URL' is assigned a value but never used

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();
});
});
Loading
Loading