Skip to content
Open
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
5 changes: 0 additions & 5 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions packages/phishing-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
136 changes: 136 additions & 0 deletions packages/phishing-controller/src/PhishingController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PHISHING_DETECTION_BULK_SCAN_ENDPOINT,
SECURITY_ALERTS_BASE_URL,
ADDRESS_SCAN_ENDPOINT,
APPROVALS_ENDPOINT,
} from './PhishingController';
import type {
PhishingControllerOptions,
Expand Down Expand Up @@ -3428,6 +3429,141 @@ describe('PhishingController', () => {
expect(cachedResult2).toMatchObject(mockResponse2);
});
});

describe('getApprovals', () => {
let controller: PhishingController;

const testChainId = '0x1';
const testAddress = '0x1234567890123456789012345678901234567890';
const mockApproval = {
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',
},
],
},
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', () => {
Expand Down
74 changes: 71 additions & 3 deletions packages/phishing-controller/src/PhishingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type {
TokenScanApiResponse,
AddressScanCacheData,
AddressScanResult,
ApprovalsResponse,
} from './types';
import {
applyDiffs,
Expand Down Expand Up @@ -62,10 +63,10 @@ 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';
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Production URL replaced with localhost debug URL

High Severity

SECURITY_ALERTS_BASE_URL has been changed from the production endpoint https://security-alerts.api.cx.metamask.io to http://localhost:3000. This breaks all security alert functionality in production — not just the new getApprovals method, but also the existing scanAddress and bulkScanTokens methods that rely on the same constant. Additionally, the protocol was downgraded from HTTPS to HTTP.

Fix in Cursor Fix in Web

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
Expand Down Expand Up @@ -399,6 +400,11 @@ export type PhishingControllerScanAddressAction = {
handler: PhishingController['scanAddress'];
};

export type PhishingControllerGetApprovalsAction = {
type: `${typeof controllerName}:getApprovals`;
handler: PhishingController['getApprovals'];
};

export type PhishingControllerGetStateAction = ControllerGetStateAction<
typeof controllerName,
PhishingControllerState
Expand All @@ -410,7 +416,8 @@ export type PhishingControllerActions =
| TestOrigin
| PhishingControllerBulkScanUrlsAction
| PhishingControllerBulkScanTokensAction
| PhishingControllerScanAddressAction;
| PhishingControllerScanAddressAction
| PhishingControllerGetApprovalsAction;

export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent<
typeof controllerName,
Expand Down Expand Up @@ -600,6 +607,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),
);
}

/**
Expand Down Expand Up @@ -1309,6 +1321,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<ApprovalsResponse> => {
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.
*
Expand Down
9 changes: 9 additions & 0 deletions packages/phishing-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,21 @@ export type {
PhishingDetectionScanResult,
AddressScanResult,
BulkTokenScanResponse,
ApprovalsResponse,
Approval,
Allowance,
ApprovalAsset,
Exposure,
Spender,
ApprovalFeature,
} from './types';
export type { TokenScanCacheData } from './types';
export { TokenScanResultType } from './types';
export {
PhishingDetectorResultType,
RecommendedAction,
AddressScanResultType,
ApprovalResultType,
ApprovalFeatureType,
} from './types';
export type { CacheEntry } from './CacheManager';
59 changes: 59 additions & 0 deletions packages/phishing-controller/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
/**
* Represents the result of checking a domain.
*/
Expand Down Expand Up @@ -262,3 +263,61 @@ 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 = {
value: string;
usd_price: string;
};

export type ApprovalAsset = {
address: string;
symbol: string;
name: string;
decimals: number;
logo_url?: string;
type?: string;
};

export type Exposure = {
usd_price: string;
value: string;
raw_value: string;
};

export type Spender = {
address: string;
label?: string;
features?: ApprovalFeature[];
};

export type Approval = {
allowance: Allowance;
asset: ApprovalAsset;
exposure: Exposure;
spender: Spender;
verdict: ApprovalResultType;
};

export type ApprovalsResponse = {
approvals: Approval[];
};
Loading