diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e1ad67225a3..6948c51e2a9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -584,9 +584,6 @@ } }, "packages/bridge-controller/tests/mock-sse.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 10 - }, "id-length": { "count": 2 } diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 513575c59b2..8effad53afc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Consume `token_warning` SSE events from the bridge-api quote stream and expose them as `tokenWarnings` in `BridgeControllerState` ([#8198](https://github.com/MetaMask/core/pull/8198)) +- Export `TokenFeature` type and `TokenFeatureType` enum for use by clients ([#8198](https://github.com/MetaMask/core/pull/8198)) + ### Changed - Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index 7846f229bc9..b22c5426da0 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -186,6 +186,7 @@ exports[`BridgeController SSE should rethrow error from server 1`] = ` "quotesInitialLoadTime": null, "quotesLoadingStatus": 0, "quotesRefreshCount": 0, + "tokenWarnings": [], } `; @@ -301,6 +302,7 @@ exports[`BridgeController SSE should trigger quote polling if request is valid 1 "quotesInitialLoadTime": null, "quotesLoadingStatus": 0, "quotesRefreshCount": 0, + "tokenWarnings": [], } `; diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index 0fc2daeefc9..a85a63acf59 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -23,6 +23,7 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = ` "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, "quotesRefreshCount": 1, + "tokenWarnings": [], } `; @@ -49,6 +50,7 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = ` "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, "quotesRefreshCount": 1, + "tokenWarnings": [], } `; @@ -812,6 +814,7 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po "quotesLastFetched": null, "quotesLoadingStatus": 0, "quotesRefreshCount": 0, + "tokenWarnings": [], } `; @@ -840,6 +843,7 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po "quotesInitialLoadTime": 10000, "quotesLoadingStatus": 1, "quotesRefreshCount": 1, + "tokenWarnings": [], } `; diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 6eaeee73700..92bb66a961b 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -10,7 +10,7 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, ETH_USDT_ADDRESS, } from './constants/bridge'; -import { ChainId, RequestStatus } from './types'; +import { ChainId, RequestStatus, TokenFeatureType } from './types'; import type { BridgeControllerMessenger, QuoteResponse, TxData } from './types'; import * as balanceUtils from './utils/balance'; import { formatChainIdToDec } from './utils/caip-formatters'; @@ -25,6 +25,7 @@ import { advanceToNthTimerThenFlush, mockSseEventSource, mockSseEventSourceWithMultipleDelays, + mockSseEventSourceWithWarnings, mockSseServerError, } from '../tests/mock-sse'; @@ -193,8 +194,9 @@ describe('BridgeController SSE', function () { 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { - onValidationFailure: expect.any(Function), + onQuoteValidationFailure: expect.any(Function), onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), onClose: expect.any(Function), }, '13.8.0', @@ -332,8 +334,9 @@ describe('BridgeController SSE', function () { 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { - onValidationFailure: expect.any(Function), + onQuoteValidationFailure: expect.any(Function), onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), onClose: expect.any(Function), }, '13.8.0', @@ -465,8 +468,9 @@ describe('BridgeController SSE', function () { 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { - onValidationFailure: expect.any(Function), + onQuoteValidationFailure: expect.any(Function), onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), onClose: expect.any(Function), }, '13.8.0', @@ -1098,8 +1102,9 @@ describe('BridgeController SSE', function () { 'AUTH_TOKEN', BRIDGE_PROD_API_BASE_URL, { - onValidationFailure: expect.any(Function), + onQuoteValidationFailure: expect.any(Function), onValidQuoteReceived: expect.any(Function), + onTokenWarning: expect.any(Function), onClose: expect.any(Function), }, '13.8.0', @@ -1140,4 +1145,64 @@ describe('BridgeController SSE', function () { // eslint-disable-next-line jest/no-restricted-matchers expect(trackMetaMetricsFn.mock.calls).toMatchSnapshot(); }); + + it('should populate tokenWarnings from token_warning SSE events', async function () { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning], + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + + // After stream completes + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + expect(bridgeController.state.quotes.length).toBeGreaterThan(0); + }); + + it('should clear tokenWarnings on resetState', async function () { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning], + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + + bridgeController.resetState(); + expect(bridgeController.state.tokenWarnings).toStrictEqual([]); + }); }); diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index b2b5805fca2..0aa2d6aeeb6 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -3474,6 +3474,7 @@ describe('BridgeController', function () { "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, + "tokenWarnings": [], } `); }); @@ -3508,6 +3509,7 @@ describe('BridgeController', function () { "quotesLastFetched": null, "quotesLoadingStatus": null, "quotesRefreshCount": 0, + "tokenWarnings": [], } `); }); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 4509ac17d0f..82a497fe4a4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -140,6 +140,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + tokenWarnings: { + includeInStateLogs: true, + persist: false, + includeInDebugSnapshot: false, + usedInUi: true, + }, }; /** @@ -392,7 +398,7 @@ export class BridgeController extends StaticIntervalPollingController { + readonly #trackQuoteValidationFailures = (validationFailures: string[]) => { if (validationFailures.length === 0) { return; } @@ -609,6 +613,7 @@ export class BridgeController extends StaticIntervalPollingController { state.quoteRequest = updatedQuoteRequest; state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError; + state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings; state.quotesLastFetched = Date.now(); state.quotesLoadingStatus = RequestStatus.LOADING; }); @@ -794,7 +800,7 @@ export class BridgeController extends StaticIntervalPollingController { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( @@ -837,6 +843,11 @@ export class BridgeController extends StaticIntervalPollingController { + this.update((state) => { + state.tokenWarnings = [...state.tokenWarnings, warning]; + }); + }, onClose: async () => { // Wait for all pending appendFeesToQuotes operations to complete // before setting quotesLoadingStatus to FETCHED diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index 38be88540a1..0763d0e7d5c 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -90,6 +90,7 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = { quotesRefreshCount: 0, assetExchangeRates: {}, minimumBalanceForRentExemptionInLamports: '0', + tokenWarnings: [], }; export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record = { diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index c7e5db53dbf..add056d3427 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -68,6 +68,8 @@ export { RequestStatus, BridgeUserAction, BridgeBackgroundAction, + TokenFeatureType, + type TokenFeature, type BridgeControllerGetStateAction, type BridgeControllerStateChangeEvent, } from './types'; diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 7ee7cc18997..89447ee03a9 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -41,6 +41,7 @@ import type { QuoteResponseSchema, QuoteSchema, StepSchema, + TokenFeatureSchema, TronTradeDataSchema, TxDataSchema, } from './utils/validators'; @@ -322,6 +323,15 @@ export enum ChainId { export type FeatureFlagsPlatformConfig = Infer; +export enum TokenFeatureType { + MALICIOUS = 'Malicious', + WARNING = 'Warning', + INFO = 'Info', + BENIGN = 'Benign', +} + +export type TokenFeature = Infer; + export enum RequestStatus { LOADING, FETCHED, @@ -376,6 +386,11 @@ export type BridgeControllerState = { * the max amount that can be sent. */ minimumBalanceForRentExemptionInLamports: string | null; + /** + * Security alerts for the destination token in the current quote request, + * populated from `token_warning` SSE events. + */ + tokenWarnings: TokenFeature[]; }; export type BridgeControllerAction< diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index b69d7e982e2..11091b651fa 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -10,13 +10,18 @@ import { import { fetchServerEvents } from './fetch-server-events'; import { isEvmTxData } from './trade-utils'; import type { FeatureId } from './validators'; -import { validateQuoteResponse, validateSwapsTokenObject } from './validators'; +import { + validateQuoteResponse, + validateSwapsTokenObject, + validateTokenFeature, +} from './validators'; import type { QuoteResponse, FetchFunction, GenericQuoteRequest, QuoteRequest, BridgeAsset, + TokenFeature, } from '../types'; export const getClientHeaders = ({ @@ -292,8 +297,9 @@ export const fetchAssetPrices = async ( * @param jwt - The JWT token for authentication * @param bridgeApiBaseUrl - The base URL for the bridge API * @param serverEventHandlers - The server event handlers - * @param serverEventHandlers.onValidationFailure - The function to handle validation failures + * @param serverEventHandlers.onQuoteValidationFailure - The function to handle quote validation failures * @param serverEventHandlers.onValidQuoteReceived - The function to handle valid quotes + * @param serverEventHandlers.onTokenWarning - The function to handle token warning events * @param serverEventHandlers.onClose - The function to run when the stream is closed and there are no thrown errors * @param clientVersion - The client version for metrics (optional) * @returns A list of bridge tx quote promises @@ -307,14 +313,15 @@ export async function fetchBridgeQuoteStream( bridgeApiBaseUrl: string, serverEventHandlers: { onClose: () => void | Promise; - onValidationFailure: (validationFailures: string[]) => void; + onQuoteValidationFailure: (validationFailures: string[]) => void; onValidQuoteReceived: (quotes: QuoteResponse) => Promise; + onTokenWarning: (warning: TokenFeature) => void; }, clientVersion?: string, ): Promise { const queryParams = formatQueryParams(request); - const onMessage = async (quoteResponse: unknown): Promise => { + const onQuoteReceived = async (quoteResponse: unknown): Promise => { const uniqueValidationFailures: Set = new Set([]); try { @@ -349,7 +356,7 @@ export async function fetchBridgeQuoteStream( const validationFailures = Array.from(uniqueValidationFailures); if (uniqueValidationFailures.size > 0) { console.warn('Quote validation failed', validationFailures); - return serverEventHandlers.onValidationFailure(validationFailures); + return serverEventHandlers.onQuoteValidationFailure(validationFailures); } // Rethrow any unexpected errors throw error; @@ -357,6 +364,30 @@ export async function fetchBridgeQuoteStream( return undefined; }; + const onTokenWarningReceived = (data: unknown): void => { + try { + if (validateTokenFeature(data)) { + serverEventHandlers.onTokenWarning(data); + } + } catch (error) { + console.warn('Token warning validation failed', error); + } + }; + + const onMessage = async ( + data: Record, + eventName?: string, + ): Promise => { + switch (eventName) { + case 'quote': + return await onQuoteReceived(data); + case 'token_warning': + return onTokenWarningReceived(data); + default: + return undefined; + } + }; + const urlStream = `${bridgeApiBaseUrl}/getQuoteStream?${queryParams}`; await fetchServerEvents(urlStream, { headers: { diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index f2e85cb1bb8..48987c27d45 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -475,3 +475,16 @@ export const validateQuoteResponse = ( assert(data, QuoteResponseSchema); return true; }; + +export const TokenFeatureSchema = type({ + feature_id: string(), + type: enums(['Malicious', 'Warning', 'Info', 'Benign']), + description: string(), +}); + +export const validateTokenFeature = ( + data: unknown, +): data is Infer => { + assert(data, TokenFeatureSchema); + return true; +}; diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index 5bc826c9f4e..c48c8209de8 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -2,15 +2,17 @@ import { ReadableStream } from 'node:stream/web'; import { flushPromises } from '../../../tests/helpers'; -import type { QuoteResponse, Trade } from '../src'; +import type { QuoteResponse, TokenFeature, Trade } from '../src'; -export const advanceToNthTimer = (n = 1) => { +type MockSseResponse = { status: number; ok: boolean; body: ReadableStream }; + +export const advanceToNthTimer = (n = 1): void => { for (let i = 0; i < n; i++) { jest.advanceTimersToNextTimer(); } }; -export const advanceToNthTimerThenFlush = async (n = 1) => { +export const advanceToNthTimerThenFlush = async (n = 1): Promise => { advanceToNthTimer(n); await flushPromises(); }; @@ -22,7 +24,7 @@ export const advanceToNthTimerThenFlush = async (n = 1) => { * @param index - the index of the event * @returns a unique event id */ -const getEventId = (index: number) => { +const getEventId = (index: number): string => { return `${Date.now().toString()}-${index}`; }; @@ -30,7 +32,7 @@ const emitLine = ( // eslint-disable-next-line n/no-unsupported-features/node-builtins controller: ReadableStreamDefaultController, line: string, -) => { +): void => { controller.enqueue(Buffer.from(line)); }; @@ -44,12 +46,12 @@ const emitLine = ( export const mockSseEventSource = ( mockQuotes: QuoteResponse[], delay: number = 3000, -) => { +): MockSseResponse => { return { status: 200, ok: true, body: new ReadableStream({ - start(controller) { + start(controller): void { setTimeout(() => { mockQuotes.forEach((quote, id) => { emitLine(controller, `event: quote\n`); @@ -73,12 +75,12 @@ export const mockSseEventSource = ( export const mockSseEventSourceWithMultipleDelays = async ( mockQuotes: QuoteResponse[], delay: number = 4000, -) => { +): Promise => { return { status: 200, ok: true, body: new ReadableStream({ - async start(controller) { + async start(controller): Promise { mockQuotes.forEach((quote, id) => { setTimeout( () => { @@ -97,6 +99,45 @@ export const mockSseEventSourceWithMultipleDelays = async ( }; }; +/** + * Simulates an SSE stream that emits both quote and token_warning events + * + * @param mockQuotes - a list of quotes to stream + * @param mockWarnings - a list of token warnings to stream + * @param delay - the delay in milliseconds + * @returns a delayed stream of quotes and token warnings + */ +export const mockSseEventSourceWithWarnings = ( + mockQuotes: QuoteResponse[], + mockWarnings: TokenFeature[], + delay: number = 3000, +): MockSseResponse => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller): void { + setTimeout(() => { + let eventIndex = 0; + mockWarnings.forEach((warning) => { + emitLine(controller, `event: token_warning\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(warning)}\n\n`); + }); + mockQuotes.forEach((quote) => { + emitLine(controller, `event: quote\n`); + // eslint-disable-next-line no-plusplus + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(quote)}\n\n`); + }); + controller.close(); + }, delay); + }, + }), + }; +}; + /** * This simulates responses from the fetch function for unit tests * @@ -107,12 +148,12 @@ export const mockSseEventSourceWithMultipleDelays = async ( export const mockSseServerError = ( errorMessage: string, delay: number = 3000, -) => { +): MockSseResponse => { return { status: 200, ok: true, body: new ReadableStream({ - start(controller) { + start(controller): void { setTimeout(() => { emitLine(controller, `event: error\n`); emitLine(controller, `id: ${getEventId(1)}\n`);