From 296ae1a34e23a79ba032dafa230e67a9ae436b74 Mon Sep 17 00:00:00 2001 From: augmentedmode Date: Sat, 28 Feb 2026 21:13:55 -0500 Subject: [PATCH 1/5] feat: add getApprovals method to PhishingController for fetching token approvals --- .../src/PhishingController.test.ts | 133 ++++++++++++++++++ .../src/PhishingController.ts | 71 +++++++++- packages/phishing-controller/src/index.ts | 9 ++ packages/phishing-controller/src/types.ts | 58 ++++++++ 4 files changed, 270 insertions(+), 1 deletion(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 9fd036ffac0..e7352147b4c 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -21,6 +21,7 @@ import { PHISHING_DETECTION_BULK_SCAN_ENDPOINT, SECURITY_ALERTS_BASE_URL, ADDRESS_SCAN_ENDPOINT, + APPROVALS_ENDPOINT, } from './PhishingController'; import type { PhishingControllerOptions, @@ -3428,6 +3429,138 @@ describe('PhishingController', () => { expect(cachedResult2).toMatchObject(mockResponse2); }); }); + + describe('getApprovals', () => { + let controller: PhishingController; + + const testChainId = '0x1'; + const testAddress = '0x1234567890123456789012345678901234567890'; + const mockApproval = { + allowance: { amount: '1000000', is_unlimited: false }, + asset: { + address: '0xtoken', + symbol: 'TKN', + name: 'Token', + decimals: 18, + }, + exposure: { usd: 100 }, + spender: { address: '0xspender' }, + verdict: 'Benign', + }; + const mockResponse = { approvals: [mockApproval] }; + + beforeEach(() => { + controller = getPhishingController(); + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'], now: 0 }); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('will return approvals for a valid address and chain', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await controller.getApprovals(testChainId, testAddress); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will return empty approvals when address is missing', async () => { + const response = await controller.getApprovals(testChainId, ''); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it('will return empty approvals when chainId is missing', async () => { + const response = await controller.getApprovals('', testAddress); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it('will return empty approvals for unknown chain ID', async () => { + const response = await controller.getApprovals( + '0x999999', + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + }); + + it.each([ + [400, 'Bad Request'], + [500, 'Internal Server Error'], + ])( + 'will return empty approvals on %i HTTP error', + async (statusCode) => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(statusCode); + + const response = await controller.getApprovals( + testChainId, + testAddress, + ); + expect(response).toStrictEqual({ approvals: [] }); + expect(scope.isDone()).toBe(true); + }, + ); + + it('will return empty approvals on timeout', async () => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .delayConnection(10000) + .reply(200, mockResponse); + + const promise = controller.getApprovals(testChainId, testAddress); + jest.advanceTimersByTime(5000); + const response = await promise; + expect(response).toStrictEqual({ approvals: [] }); + expect(scope.isDone()).toBe(false); + }); + + it('will normalize address to lowercase before API call', async () => { + const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: mixedCaseAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await controller.getApprovals( + testChainId, + mixedCaseAddress, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + + it('will normalize chainId and resolve to chain name', async () => { + const mixedCaseChainId = '0xA'; + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'optimism', + address: testAddress.toLowerCase(), + }) + .reply(200, mockResponse); + + const response = await controller.getApprovals( + mixedCaseChainId, + testAddress, + ); + expect(response).toStrictEqual(mockResponse); + expect(scope.isDone()).toBe(true); + }); + }); }); describe('URL Scan Cache', () => { diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 6fba169f9a9..74900206bfc 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -35,6 +35,7 @@ import type { TokenScanApiResponse, AddressScanCacheData, AddressScanResult, + ApprovalsResponse, } from './types'; import { applyDiffs, @@ -66,6 +67,7 @@ export const SECURITY_ALERTS_BASE_URL = 'https://security-alerts.api.cx.metamask.io'; export const TOKEN_BULK_SCANNING_ENDPOINT = '/token/scan-bulk'; export const ADDRESS_SCAN_ENDPOINT = '/address/evm/scan'; +export const APPROVALS_ENDPOINT = '/address/evm/approvals'; // Cache configuration defaults export const DEFAULT_URL_SCAN_CACHE_TTL = 15 * 60; // 15 minutes in seconds @@ -399,6 +401,11 @@ export type PhishingControllerScanAddressAction = { handler: PhishingController['scanAddress']; }; +export type PhishingControllerGetApprovalsAction = { + type: `${typeof controllerName}:getApprovals`; + handler: PhishingController['getApprovals']; +}; + export type PhishingControllerGetStateAction = ControllerGetStateAction< typeof controllerName, PhishingControllerState @@ -410,7 +417,8 @@ export type PhishingControllerActions = | TestOrigin | PhishingControllerBulkScanUrlsAction | PhishingControllerBulkScanTokensAction - | PhishingControllerScanAddressAction; + | PhishingControllerScanAddressAction + | PhishingControllerGetApprovalsAction; export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -600,6 +608,11 @@ export class PhishingController extends BaseController< `${controllerName}:scanAddress` as const, this.scanAddress.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:getApprovals` as const, + this.getApprovals.bind(this), + ); } /** @@ -1309,6 +1322,62 @@ export class PhishingController extends BaseController< }; }; + /** + * Get token approvals for an EVM address with security enrichments. + * + * @param chainId - The chain ID in hex format (e.g., '0x1' for Ethereum). + * @param address - The address to get approvals for. + * @returns The approvals response containing approval data, or empty approvals on error. + */ + getApprovals = async ( + chainId: string, + address: string, + ): Promise => { + if (!address || !chainId) { + return { approvals: [] }; + } + + const normalizedChainId = chainId.toLowerCase(); + const normalizedAddress = address.toLowerCase(); + const chain = resolveChainName(normalizedChainId); + + if (!chain) { + return { approvals: [] }; + } + + const apiResponse = await safelyExecuteWithTimeout( + async () => { + const res = await fetch( + `${SECURITY_ALERTS_BASE_URL}${APPROVALS_ENDPOINT}`, + { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + chain, + address: normalizedAddress, + }), + }, + ); + if (!res.ok) { + return { error: `${res.status} ${res.statusText}` }; + } + const data: ApprovalsResponse = await res.json(); + return data; + }, + true, + 5000, + ); + + if (!apiResponse || 'error' in apiResponse) { + return { approvals: [] }; + } + + return apiResponse; + }; + /** * Scan multiple tokens for malicious activity in bulk. * diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 33b89b6e06c..12423afd7c3 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -11,6 +11,13 @@ export type { PhishingDetectionScanResult, AddressScanResult, BulkTokenScanResponse, + ApprovalsResponse, + Approval, + Allowance, + ApprovalAsset, + Exposure, + Spender, + ApprovalFeature, } from './types'; export type { TokenScanCacheData } from './types'; export { TokenScanResultType } from './types'; @@ -18,5 +25,7 @@ export { PhishingDetectorResultType, RecommendedAction, AddressScanResultType, + ApprovalResultType, + ApprovalFeatureType, } from './types'; export type { CacheEntry } from './CacheManager'; diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index 169885ecfdd..b06b88c8765 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/naming-convention */ /** * Represents the result of checking a domain. */ @@ -262,3 +263,60 @@ export type AddressScanCacheData = { result_type: AddressScanResultType; label: string; }; + +export enum ApprovalResultType { + Malicious = 'Malicious', + Warning = 'Warning', + Benign = 'Benign', + ErrorResult = 'Error', +} + +export enum ApprovalFeatureType { + Malicious = 'Malicious', + Warning = 'Warning', + Benign = 'Benign', + Info = 'Info', +} + +export type ApprovalFeature = { + feature_id: string; + type: ApprovalFeatureType; + description: string; +}; + +export type Allowance = { + amount: string; + is_unlimited: boolean; +}; + +export type ApprovalAsset = { + address: string; + symbol: string; + name: string; + decimals: number; + logo_url?: string; + type?: string; +}; + +export type Exposure = { + usd: number; +}; + +export type Spender = { + address: string; + label?: string; + is_verified?: boolean; +}; + +export type Approval = { + allowance: Allowance; + asset: ApprovalAsset; + exposure: Exposure; + spender: Spender; + verdict: ApprovalResultType; + features?: ApprovalFeature[]; +}; + +export type ApprovalsResponse = { + approvals: Approval[]; +}; From ab33cb8316ec5eefdb656f1ab5cb45fff801336c Mon Sep 17 00:00:00 2001 From: augmentedmode Date: Sat, 28 Feb 2026 21:25:00 -0500 Subject: [PATCH 2/5] feat: add getApprovals method to PhishingController for fetching token approvals fix --- .../src/PhishingController.test.ts | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index e7352147b4c..b98db6eb5f3 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -3482,34 +3482,25 @@ describe('PhishingController', () => { }); it('will return empty approvals for unknown chain ID', async () => { - const response = await controller.getApprovals( - '0x999999', - testAddress, - ); + const response = await controller.getApprovals('0x999999', testAddress); expect(response).toStrictEqual({ approvals: [] }); }); it.each([ [400, 'Bad Request'], [500, 'Internal Server Error'], - ])( - 'will return empty approvals on %i HTTP error', - async (statusCode) => { - const scope = nock(SECURITY_ALERTS_BASE_URL) - .post(APPROVALS_ENDPOINT, { - chain: 'ethereum', - address: testAddress.toLowerCase(), - }) - .reply(statusCode); + ])('will return empty approvals on %i HTTP error', async (statusCode) => { + const scope = nock(SECURITY_ALERTS_BASE_URL) + .post(APPROVALS_ENDPOINT, { + chain: 'ethereum', + address: testAddress.toLowerCase(), + }) + .reply(statusCode); - const response = await controller.getApprovals( - testChainId, - testAddress, - ); - expect(response).toStrictEqual({ approvals: [] }); - expect(scope.isDone()).toBe(true); - }, - ); + const response = await controller.getApprovals(testChainId, testAddress); + expect(response).toStrictEqual({ approvals: [] }); + expect(scope.isDone()).toBe(true); + }); it('will return empty approvals on timeout', async () => { const scope = nock(SECURITY_ALERTS_BASE_URL) From 3ea4dbfb8cc325072d5a965cf063e69519667ed5 Mon Sep 17 00:00:00 2001 From: augmentedmode Date: Sat, 28 Feb 2026 21:30:12 -0500 Subject: [PATCH 3/5] fix: eslint supressions --- eslint-suppressions.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 2c57c22256e..269e82d6b09 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1342,11 +1342,6 @@ "count": 2 } }, - "packages/phishing-controller/src/types.ts": { - "@typescript-eslint/naming-convention": { - "count": 4 - } - }, "packages/phishing-controller/src/utils.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 2 From 17220732dcc3933a92678eb0ceaaae09d9c39600 Mon Sep 17 00:00:00 2001 From: augmentedmode Date: Sat, 28 Feb 2026 21:35:17 -0500 Subject: [PATCH 4/5] docs: add changelog entry for getApprovals Co-Authored-By: Claude Opus 4.6 --- packages/phishing-controller/CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index b2f08741e4c..21a93953a63 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getApprovals` method and messenger action to fetch token approvals with security enrichments from the security alerts API ([#8074](https://github.com/MetaMask/core/pull/8074)) +- Export approval-related types: `ApprovalsResponse`, `Approval`, `Allowance`, `ApprovalAsset`, `Exposure`, `Spender`, `ApprovalFeature`, `ApprovalResultType`, `ApprovalFeatureType` ([#8074](https://github.com/MetaMask/core/pull/8074)) + ### Changed - Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.19.0` ([#7996](https://github.com/MetaMask/core/pull/7996), [#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) From ed84f19df62ef4f2a1fcfe17fff51ef85a7e7a5f Mon Sep 17 00:00:00 2001 From: augmentedmode Date: Sun, 1 Mar 2026 12:25:57 -0500 Subject: [PATCH 5/5] fix: types --- .../src/PhishingController.test.ts | 18 +++++++++++++++--- .../src/PhishingController.ts | 3 +-- packages/phishing-controller/src/types.ts | 11 ++++++----- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index b98db6eb5f3..9680755e8dc 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -3436,15 +3436,27 @@ describe('PhishingController', () => { const testChainId = '0x1'; const testAddress = '0x1234567890123456789012345678901234567890'; const mockApproval = { - allowance: { amount: '1000000', is_unlimited: false }, + allowance: { value: '1000000', usd_price: '1000.00' }, asset: { + type: 'ERC20', address: '0xtoken', symbol: 'TKN', name: 'Token', decimals: 18, + logo_url: 'https://example.com/token.png', + }, + exposure: { usd_price: '100.00', value: '100.00', raw_value: '0x64' }, + spender: { + address: '0xspender', + label: 'Uniswap', + features: [ + { + type: 'Benign', + feature_id: 'VERIFIED_CONTRACT', + description: 'This contract is verified', + }, + ], }, - exposure: { usd: 100 }, - spender: { address: '0xspender' }, verdict: 'Benign', }; const mockResponse = { approvals: [mockApproval] }; diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 74900206bfc..5c92d31edcf 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -63,8 +63,7 @@ export const PHISHING_DETECTION_BASE_URL = export const PHISHING_DETECTION_SCAN_ENDPOINT = 'v2/scan'; export const PHISHING_DETECTION_BULK_SCAN_ENDPOINT = 'bulk-scan'; -export const SECURITY_ALERTS_BASE_URL = - 'https://security-alerts.api.cx.metamask.io'; +export const SECURITY_ALERTS_BASE_URL = 'http://localhost:3000'; export const TOKEN_BULK_SCANNING_ENDPOINT = '/token/scan-bulk'; export const ADDRESS_SCAN_ENDPOINT = '/address/evm/scan'; export const APPROVALS_ENDPOINT = '/address/evm/approvals'; diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index b06b88c8765..c8ff38824e6 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -285,8 +285,8 @@ export type ApprovalFeature = { }; export type Allowance = { - amount: string; - is_unlimited: boolean; + value: string; + usd_price: string; }; export type ApprovalAsset = { @@ -299,13 +299,15 @@ export type ApprovalAsset = { }; export type Exposure = { - usd: number; + usd_price: string; + value: string; + raw_value: string; }; export type Spender = { address: string; label?: string; - is_verified?: boolean; + features?: ApprovalFeature[]; }; export type Approval = { @@ -314,7 +316,6 @@ export type Approval = { exposure: Exposure; spender: Spender; verdict: ApprovalResultType; - features?: ApprovalFeature[]; }; export type ApprovalsResponse = {