From 35f136f80cad239ac8ccd1966ea1d50217800e7f Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:10:32 -0400 Subject: [PATCH 01/19] chore: add types and validators for token feature --- packages/bridge-controller/src/types.ts | 15 +++++++++++++++ .../bridge-controller/src/utils/validators.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+) 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/validators.ts b/packages/bridge-controller/src/utils/validators.ts index f2e85cb1bb8..45d57869929 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -475,3 +475,15 @@ 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 => { + return is(data, TokenFeatureSchema); +}; From 7ba4541d1acb4d9e71ffaddc78865f1bb0ae2adb Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:10:50 -0400 Subject: [PATCH 02/19] chore: export types --- packages/bridge-controller/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) 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'; From 41f24abb6447e47e7e9e243e749c02cdd4b4fbd7 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:13:38 -0400 Subject: [PATCH 03/19] chore: match token validation --- packages/bridge-controller/src/utils/validators.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 45d57869929..48987c27d45 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -485,5 +485,6 @@ export const TokenFeatureSchema = type({ export const validateTokenFeature = ( data: unknown, ): data is Infer => { - return is(data, TokenFeatureSchema); + assert(data, TokenFeatureSchema); + return true; }; From 73d3a5b397c9ec3f2746ce63af0e84e4441c243f Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:15:33 -0400 Subject: [PATCH 04/19] chore: rename onValidationFailure to be more specific to onQuoteValidationFailuer --- packages/bridge-controller/src/bridge-controller.ts | 6 +++--- packages/bridge-controller/src/utils/fetch.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 4509ac17d0f..ac870b172b7 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -392,7 +392,7 @@ export class BridgeController extends StaticIntervalPollingController { if (validationFailures.length === 0) { @@ -794,7 +794,7 @@ export class BridgeController extends StaticIntervalPollingController { const feeAppendPromise = (async () => { const quotesWithFees = await appendFeesToQuotes( diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index b69d7e982e2..c8bf0aebf2c 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -292,7 +292,7 @@ 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.onClose - The function to run when the stream is closed and there are no thrown errors * @param clientVersion - The client version for metrics (optional) @@ -307,7 +307,7 @@ export async function fetchBridgeQuoteStream( bridgeApiBaseUrl: string, serverEventHandlers: { onClose: () => void | Promise; - onValidationFailure: (validationFailures: string[]) => void; + onQuoteValidationFailure: (validationFailures: string[]) => void; onValidQuoteReceived: (quotes: QuoteResponse) => Promise; }, clientVersion?: string, @@ -349,7 +349,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; From 41ab6a49eacd6bd52a69e830582a90551a4dfa50 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:16:30 -0400 Subject: [PATCH 05/19] feat: handle token warning events --- packages/bridge-controller/src/utils/fetch.ts | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index c8bf0aebf2c..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 = ({ @@ -294,6 +299,7 @@ export const fetchAssetPrices = async ( * @param serverEventHandlers - The server event handlers * @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 @@ -309,12 +315,13 @@ export async function fetchBridgeQuoteStream( onClose: () => void | Promise; 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 { @@ -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: { From aabe47161c6b1258b8186c9a0fbd4b2583b75a18 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:17:33 -0400 Subject: [PATCH 06/19] feat: add tokenWarnings to state and update when needed --- packages/bridge-controller/src/bridge-controller.ts | 13 +++++++++++++ packages/bridge-controller/src/constants/bridge.ts | 1 + 2 files changed, 14 insertions(+) diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ac870b172b7..ac739d0d1fc 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, + }, }; /** @@ -609,6 +615,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; }); @@ -837,6 +845,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 = { From e5bced7eb6615e0ad81f0dee6907801b0e51a49c Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:17:39 -0400 Subject: [PATCH 07/19] chore: tests --- .../bridge-controller.sse.test.ts.snap | 2 + .../bridge-controller.test.ts.snap | 4 + .../src/bridge-controller.sse.test.ts | 75 +++++++++++++++++-- .../src/bridge-controller.test.ts | 2 + packages/bridge-controller/tests/mock-sse.ts | 39 +++++++++- 5 files changed, 116 insertions(+), 6 deletions(-) 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/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index 5bc826c9f4e..d49a2fd07b3 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -2,7 +2,7 @@ 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) => { for (let i = 0; i < n; i++) { @@ -97,6 +97,43 @@ 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, +) => { + return { + status: 200, + ok: true, + body: new ReadableStream({ + start(controller) { + setTimeout(() => { + let eventIndex = 0; + mockWarnings.forEach((warning) => { + emitLine(controller, `event: token_warning\n`); + emitLine(controller, `id: ${getEventId(eventIndex++)}\n`); + emitLine(controller, `data: ${JSON.stringify(warning)}\n\n`); + }); + mockQuotes.forEach((quote) => { + emitLine(controller, `event: quote\n`); + 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 * From e97c1203b3c24ede351c68aac9657588e3b1e80f Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 12 Mar 2026 16:20:07 -0400 Subject: [PATCH 08/19] chore: changelog --- packages/bridge-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 513575c59b2..20b2df9d683 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,6 +12,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) - Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) +### 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)) + ## [69.1.1] ### Changed From 6d8a8229e5f8f931e124dbcfbc0d171977ce3fa4 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 18 Mar 2026 15:44:52 -0400 Subject: [PATCH 09/19] chore: format --- packages/bridge-controller/src/bridge-controller.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ac739d0d1fc..82a497fe4a4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -410,9 +410,7 @@ export class BridgeController extends StaticIntervalPollingController { + readonly #trackQuoteValidationFailures = (validationFailures: string[]) => { if (validationFailures.length === 0) { return; } From 48b28d5b7c839de703538429e0cbe0952c82c311 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Wed, 18 Mar 2026 17:08:59 -0400 Subject: [PATCH 10/19] fix: changelog --- packages/bridge-controller/CHANGELOG.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 20b2df9d683..8effad53afc 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,16 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Bump `@metamask/assets-controller` from `^2.4.0` to `^3.0.0` ([#8232](https://github.com/MetaMask/core/pull/8232)) -- Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) - ### 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)) +- Bump `@metamask/assets-controllers` from `^101.0.0` to `^101.0.1` ([#8232](https://github.com/MetaMask/core/pull/8232)) + ## [69.1.1] ### Changed From d02bc20e73605af0c04bd8409f40e9a678a2b2d1 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 19 Mar 2026 15:56:25 -0400 Subject: [PATCH 11/19] fix: lint --- packages/bridge-controller/tests/mock-sse.ts | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index d49a2fd07b3..b67e2f66b4f 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -4,13 +4,15 @@ import { ReadableStream } from 'node:stream/web'; import { flushPromises } from '../../../tests/helpers'; 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( () => { @@ -109,12 +111,12 @@ export const mockSseEventSourceWithWarnings = ( mockQuotes: QuoteResponse[], mockWarnings: TokenFeature[], delay: number = 3000, -) => { +): MockSseResponse => { return { status: 200, ok: true, body: new ReadableStream({ - start(controller) { + start(controller): void { setTimeout(() => { let eventIndex = 0; mockWarnings.forEach((warning) => { @@ -144,12 +146,12 @@ export const mockSseEventSourceWithWarnings = ( 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`); From c40de26fcce14e9e6f4ebbc9417273c743164eda Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:09:13 -0400 Subject: [PATCH 12/19] fix: lint --- packages/bridge-controller/tests/mock-sse.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/bridge-controller/tests/mock-sse.ts b/packages/bridge-controller/tests/mock-sse.ts index b67e2f66b4f..c48c8209de8 100644 --- a/packages/bridge-controller/tests/mock-sse.ts +++ b/packages/bridge-controller/tests/mock-sse.ts @@ -121,11 +121,13 @@ export const mockSseEventSourceWithWarnings = ( 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`); }); From 2f436e0412072adf3a3e1b963e896e161635d6ae Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:47:22 -0400 Subject: [PATCH 13/19] fix: lint --- eslint-suppressions.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index e1ad67225a3..28f39d36312 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 } @@ -1793,4 +1790,4 @@ "count": 1 } } -} +} \ No newline at end of file From affb8df596362971fb9fe361d50572d58fd3924a Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:57:07 -0400 Subject: [PATCH 14/19] fix: lint --- eslint-suppressions.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 28f39d36312..6948c51e2a9 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1790,4 +1790,4 @@ "count": 1 } } -} \ No newline at end of file +} From 195183342e464abf5e1bd24ba0c3b8f6442eae0a Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 20 Mar 2026 11:07:01 -0400 Subject: [PATCH 15/19] chore: use enum for token feature type --- .../bridge-controller/src/bridge-controller.sse.test.ts | 3 ++- packages/bridge-controller/src/index.ts | 2 +- packages/bridge-controller/src/types.ts | 7 ------- packages/bridge-controller/src/utils/validators.ts | 9 ++++++++- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 92bb66a961b..99a28e134c7 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -10,12 +10,13 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, ETH_USDT_ADDRESS, } from './constants/bridge'; -import { ChainId, RequestStatus, TokenFeatureType } from './types'; +import { ChainId, RequestStatus } from './types'; import type { BridgeControllerMessenger, QuoteResponse, TxData } from './types'; import * as balanceUtils from './utils/balance'; import { formatChainIdToDec } from './utils/caip-formatters'; import * as featureFlagUtils from './utils/feature-flags'; import * as fetchUtils from './utils/fetch'; +import { TokenFeatureType } from './utils/validators'; import { flushPromises } from '../../../tests/helpers'; import mockBridgeQuotesErc20Erc20 from '../tests/mock-quotes-erc20-erc20.json'; import mockBridgeQuotesNativeErc20Eth from '../tests/mock-quotes-native-erc20-eth.json'; diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index add056d3427..5a8749f17d5 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -68,7 +68,6 @@ export { RequestStatus, BridgeUserAction, BridgeBackgroundAction, - TokenFeatureType, type TokenFeature, type BridgeControllerGetStateAction, type BridgeControllerStateChangeEvent, @@ -79,6 +78,7 @@ export { ActionTypes, BridgeAssetSchema, FeatureId, + TokenFeatureType, } from './utils/validators'; export { diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 89447ee03a9..7e9e5ff958c 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -323,13 +323,6 @@ 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 { diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 48987c27d45..660a76810ac 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -476,9 +476,16 @@ export const validateQuoteResponse = ( return true; }; +export enum TokenFeatureType { + MALICIOUS = 'Malicious', + WARNING = 'Warning', + INFO = 'Info', + BENIGN = 'Benign', +} + export const TokenFeatureSchema = type({ feature_id: string(), - type: enums(['Malicious', 'Warning', 'Info', 'Benign']), + type: enums(Object.values(TokenFeatureType)), description: string(), }); From 959165e9aa30068407cadf5aee9ea199f985f41f Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:37:00 -0400 Subject: [PATCH 16/19] chore: dedupe warnings in the stream --- packages/bridge-controller/src/bridge-controller.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 82a497fe4a4..34504d780cd 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -845,7 +845,14 @@ export class BridgeController extends StaticIntervalPollingController { this.update((state) => { - state.tokenWarnings = [...state.tokenWarnings, warning]; + const isDuplicate = state.tokenWarnings.some( + (existing) => + existing.type === warning.type && + existing.feature_id === warning.feature_id, + ); + if (!isDuplicate) { + state.tokenWarnings = [...state.tokenWarnings, warning]; + } }); }, onClose: async () => { From 4acd470eb879681aab5cc36ac38f3cff96662950 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:51:32 -0400 Subject: [PATCH 17/19] chore: add token warnings selector --- packages/bridge-controller/src/index.ts | 1 + .../bridge-controller/src/selectors.test.ts | 19 +++++++++++++++++++ packages/bridge-controller/src/selectors.ts | 3 +++ 3 files changed, 23 insertions(+) diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 5a8749f17d5..2839194db38 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -164,6 +164,7 @@ export { selectIsQuoteExpired, selectBridgeFeatureFlags, selectMinimumBalanceForRentExemptionInSOL, + selectTokenWarnings, } from './selectors'; export { DEFAULT_FEATURE_FLAG_CONFIG } from './constants/bridge'; diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index f05d1d2615a..ae10c03b118 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -14,6 +14,7 @@ import { selectBridgeFeatureFlags, selectMinimumBalanceForRentExemptionInSOL, selectDefaultSlippagePercentage, + selectTokenWarnings, } from './selectors'; import type { BridgeAsset, QuoteResponse } from './types'; import { SortOrder, RequestStatus, ChainId } from './types'; @@ -1734,4 +1735,22 @@ describe('Bridge Selectors', () => { expect(result).toBe(2); }); }); + + describe('selectTokenWarnings', () => { + it('should return the tokenWarnings array from state', () => { + const warnings = [ + { feature_id: 'HONEYPOT', type: 'Malicious', description: 'Token is a honeypot' }, + { feature_id: 'FAKE_TOKEN', type: 'Warning', description: 'Possible fake token' }, + ]; + const state = { tokenWarnings: warnings } as BridgeAppState; + + expect(selectTokenWarnings(state)).toBe(warnings); + }); + + it('should return an empty array when there are no warnings', () => { + const state = { tokenWarnings: [] } as BridgeAppState; + + expect(selectTokenWarnings(state)).toStrictEqual([]); + }); + }); }); diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index 8597bcb526a..0acb6dc04d4 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -565,6 +565,9 @@ export const selectMinimumBalanceForRentExemptionInSOL = ( .div(10 ** 9) .toString(); +export const selectTokenWarnings = (state: BridgeAppState) => + state.tokenWarnings; + export const selectDefaultSlippagePercentage = createBridgeSelector( [ (state) => selectBridgeFeatureFlags(state).chains, From 5eb379e9a66fc01e63e718a9bc7dfae6b4689571 Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 20 Mar 2026 12:54:24 -0400 Subject: [PATCH 18/19] chore: dedupe on feature id only, update tests --- .../src/bridge-controller.sse.test.ts | 98 +++++++++++++++++++ .../src/bridge-controller.ts | 4 +- 2 files changed, 99 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 99a28e134c7..257545b8a1a 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -1206,4 +1206,102 @@ describe('BridgeController SSE', function () { bridgeController.resetState(); expect(bridgeController.state.tokenWarnings).toStrictEqual([]); }); + + it('should deduplicate tokenWarnings with the same feature_id', async function () { + const mockWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const duplicateWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Duplicate warning', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [mockWarning, duplicateWarning], + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([mockWarning]); + }); + + it('should deduplicate tokenWarnings with the same feature_id but different type', async function () { + const maliciousWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const infoWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.INFO, + description: 'Informational notice', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [maliciousWarning, infoWarning], + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + maliciousWarning, + ]); + }); + + it('should keep tokenWarnings with the same type but different feature_id', async function () { + const honeypotWarning = { + feature_id: 'HONEYPOT', + type: TokenFeatureType.MALICIOUS, + description: 'Token is a honeypot', + }; + const fakeTokenWarning = { + feature_id: 'FAKE_TOKEN', + type: TokenFeatureType.MALICIOUS, + description: 'Possible fake token', + }; + mockFetchFn.mockImplementationOnce(async () => { + return mockSseEventSourceWithWarnings( + mockBridgeQuotesNativeErc20 as QuoteResponse[], + [honeypotWarning, fakeTokenWarning], + ); + }); + + await bridgeController.updateBridgeQuoteRequestParams( + quoteRequest, + metricsContext, + ); + + jest.advanceTimersByTime(1000); + await advanceToNthTimerThenFlush(); + jest.advanceTimersByTime(5000); + await flushPromises(); + + expect(bridgeController.state.tokenWarnings).toStrictEqual([ + honeypotWarning, + fakeTokenWarning, + ]); + }); }); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 34504d780cd..ff92d0254c4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -846,9 +846,7 @@ export class BridgeController extends StaticIntervalPollingController { this.update((state) => { const isDuplicate = state.tokenWarnings.some( - (existing) => - existing.type === warning.type && - existing.feature_id === warning.feature_id, + (existing) => existing.feature_id === warning.feature_id, ); if (!isDuplicate) { state.tokenWarnings = [...state.tokenWarnings, warning]; From 37d8983b47a805e5106d90a85a4b50580d2640ab Mon Sep 17 00:00:00 2001 From: IF <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:21:31 -0400 Subject: [PATCH 19/19] fix: lint --- packages/bridge-controller/src/selectors.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index ae10c03b118..61c6d9a37bb 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -1739,8 +1739,16 @@ describe('Bridge Selectors', () => { describe('selectTokenWarnings', () => { it('should return the tokenWarnings array from state', () => { const warnings = [ - { feature_id: 'HONEYPOT', type: 'Malicious', description: 'Token is a honeypot' }, - { feature_id: 'FAKE_TOKEN', type: 'Warning', description: 'Possible fake token' }, + { + feature_id: 'HONEYPOT', + type: 'Malicious', + description: 'Token is a honeypot', + }, + { + feature_id: 'FAKE_TOKEN', + type: 'Warning', + description: 'Possible fake token', + }, ]; const state = { tokenWarnings: warnings } as BridgeAppState;