Skip to content

Commit 901f63a

Browse files
feat: add address scanning to phishing controller (#7118)
## Explanation <!-- Thanks for your contribution! Take a moment to answer these questions so that reviewers have the information they need to properly understand your changes: * What is the current state of things and why does it need to change? * What is the solution your changes offer and how does it work? * Are there any changes whose purpose might not obvious to those unfamiliar with the domain? * If your primary goal was to update one package but you found you had to update another one along the way, why did you do so? * If you had to upgrade a dependency, why did you do so? --> ## References <!-- Are there any issues that this pull request is tied to? Are there other links that reviewers should consult to understand these changes better? Are there client or consumer pull requests to adopt any breaking changes? For example: * Fixes #12345 * Related to #67890 --> ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/contributing.md#updating-changelogs), highlighting breaking changes as necessary - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Introduces `scanAddress` with per-chain caching, new types, and messenger action to screen EVM addresses via Security Alerts API. > > - **Controller (`src/PhishingController.ts`)** > - Add `scanAddress(chainId, address)` hitting `SECURITY_ALERTS_BASE_URL + ADDRESS_SCAN_ENDPOINT` with 5s timeout; returns `AddressScanResult` and caches per `chainId+address`. > - Add `addressScanCache` (state, metadata), cache manager, defaults (`DEFAULT_ADDRESS_SCAN_CACHE_TTL/MAX_SIZE`), and options (`addressScanCacheTTL/MaxSize`). > - Register action `PhishingController:scanAddress` and export constants `SECURITY_ALERTS_BASE_URL`, `ADDRESS_SCAN_ENDPOINT`. > - **Types/Exports** > - Add `AddressScanResult`, `AddressScanResultType`, `AddressScanCacheData`; extend chain ID map; export new types/enums via `index.ts`. > - **Tests (`PhishingController.test.ts`)** > - Add comprehensive `scanAddress` tests: success, HTTP errors, timeout, validation, normalization, caching (including per-chain). > - Update metadata snapshots to include `addressScanCache`. > - **Changelog** > - Document new address scanning feature and related API/action/state additions. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6e3e5d1. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 2c96855 commit 901f63a

File tree

5 files changed

+421
-6
lines changed

5 files changed

+421
-6
lines changed

packages/phishing-controller/CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Add address scanning to detect malicious addresses ([#7118](https://github.com/MetaMask/core/pull/7118))
13+
- Add `scanAddress` method to scan addresses for security alerts
14+
- Add `AddressScanResult` type
15+
- Add `addressScanCache` to `PhishingControllerState`
16+
- Add action registration for `scanAddress` method as `PhishingControllerScanAddressAction`
17+
1018
## [15.0.1]
1119

1220
### Changed

packages/phishing-controller/src/PhishingController.test.ts

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,21 @@ import {
2424
PHISHING_DETECTION_BULK_SCAN_ENDPOINT,
2525
type BulkPhishingDetectionScanResponse,
2626
type PhishingControllerMessenger,
27+
SECURITY_ALERTS_BASE_URL,
28+
ADDRESS_SCAN_ENDPOINT,
2729
} from './PhishingController';
2830
import {
2931
createMockStateChangePayload,
3032
createMockTransaction,
3133
formatHostnameToUrl,
3234
TEST_ADDRESSES,
3335
} from './tests/utils';
34-
import type { PhishingDetectionScanResult } from './types';
35-
import { PhishingDetectorResultType, RecommendedAction } from './types';
36+
import type { PhishingDetectionScanResult, AddressScanResult } from './types';
37+
import {
38+
PhishingDetectorResultType,
39+
RecommendedAction,
40+
AddressScanResultType,
41+
} from './types';
3642
import { getHostnameFromUrl } from './utils';
3743

3844
const controllerName = 'PhishingController';
@@ -3208,6 +3214,205 @@ describe('PhishingController', () => {
32083214
expect(nock.pendingMocks()).toHaveLength(0);
32093215
});
32103216
});
3217+
3218+
describe('scanAddress', () => {
3219+
let controller: PhishingController;
3220+
let clock: sinon.SinonFakeTimers;
3221+
const testChainId = '0x1';
3222+
const testAddress = '0x1234567890123456789012345678901234567890';
3223+
const mockResponse: AddressScanResult = {
3224+
result_type: AddressScanResultType.Benign,
3225+
label: '',
3226+
};
3227+
3228+
beforeEach(() => {
3229+
controller = getPhishingController();
3230+
clock = sinon.useFakeTimers();
3231+
});
3232+
3233+
afterEach(() => {
3234+
clock.restore();
3235+
});
3236+
3237+
it('will return the scan result for a valid address', async () => {
3238+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3239+
.post(ADDRESS_SCAN_ENDPOINT, {
3240+
chain: 'ethereum',
3241+
address: testAddress.toLowerCase(),
3242+
})
3243+
.reply(200, mockResponse);
3244+
3245+
const response = await controller.scanAddress(testChainId, testAddress);
3246+
expect(response).toMatchObject(mockResponse);
3247+
expect(scope.isDone()).toBe(true);
3248+
});
3249+
3250+
it.each([
3251+
[400, 'Bad Request'],
3252+
[401, 'Unauthorized'],
3253+
[403, 'Forbidden'],
3254+
[404, 'Not Found'],
3255+
[500, 'Internal Server Error'],
3256+
[502, 'Bad Gateway'],
3257+
[503, 'Service Unavailable'],
3258+
[504, 'Gateway Timeout'],
3259+
])(
3260+
'will return an AddressScanResult with an ErrorResult on %i status code',
3261+
async (statusCode) => {
3262+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3263+
.post(ADDRESS_SCAN_ENDPOINT, {
3264+
chain: 'ethereum',
3265+
address: testAddress.toLowerCase(),
3266+
})
3267+
.reply(statusCode);
3268+
3269+
const response = await controller.scanAddress(testChainId, testAddress);
3270+
expect(response).toMatchObject({
3271+
result_type: AddressScanResultType.ErrorResult,
3272+
label: '',
3273+
});
3274+
expect(scope.isDone()).toBe(true);
3275+
},
3276+
);
3277+
3278+
it('will return an AddressScanResult with an ErrorResult on timeout', async () => {
3279+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3280+
.post(ADDRESS_SCAN_ENDPOINT, {
3281+
chain: 'ethereum',
3282+
address: testAddress.toLowerCase(),
3283+
})
3284+
.delayConnection(10000)
3285+
.reply(200, {});
3286+
3287+
const promise = controller.scanAddress(testChainId, testAddress);
3288+
clock.tick(5000);
3289+
const response = await promise;
3290+
expect(response).toMatchObject({
3291+
result_type: AddressScanResultType.ErrorResult,
3292+
label: '',
3293+
});
3294+
expect(scope.isDone()).toBe(false);
3295+
});
3296+
3297+
it('will return an AddressScanResult with an ErrorResult when address is missing', async () => {
3298+
const response = await controller.scanAddress(testChainId, '');
3299+
expect(response).toMatchObject({
3300+
result_type: AddressScanResultType.ErrorResult,
3301+
label: '',
3302+
});
3303+
});
3304+
3305+
it('will return an AddressScanResult with an ErrorResult when chain ID is unknown', async () => {
3306+
const unknownChainId = '0x999999';
3307+
const response = await controller.scanAddress(
3308+
unknownChainId,
3309+
testAddress,
3310+
);
3311+
expect(response).toMatchObject({
3312+
result_type: AddressScanResultType.ErrorResult,
3313+
label: '',
3314+
});
3315+
});
3316+
3317+
it('will normalize address to lowercase', async () => {
3318+
const mixedCaseAddress = '0xAbCdEf1234567890123456789012345678901234';
3319+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3320+
.post(ADDRESS_SCAN_ENDPOINT, {
3321+
chain: 'ethereum',
3322+
address: mixedCaseAddress.toLowerCase(),
3323+
})
3324+
.reply(200, mockResponse);
3325+
3326+
const response = await controller.scanAddress(
3327+
testChainId,
3328+
mixedCaseAddress,
3329+
);
3330+
expect(response).toMatchObject(mockResponse);
3331+
expect(scope.isDone()).toBe(true);
3332+
});
3333+
3334+
it('will normalize chain ID to lowercase', async () => {
3335+
const mixedCaseChainId = '0xA';
3336+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3337+
.post(ADDRESS_SCAN_ENDPOINT, {
3338+
chain: 'optimism',
3339+
address: testAddress.toLowerCase(),
3340+
})
3341+
.reply(200, mockResponse);
3342+
3343+
const response = await controller.scanAddress(
3344+
mixedCaseChainId,
3345+
testAddress,
3346+
);
3347+
expect(response).toMatchObject(mockResponse);
3348+
expect(scope.isDone()).toBe(true);
3349+
});
3350+
3351+
it('will cache scan results and return them on subsequent calls', async () => {
3352+
const fetchSpy = jest.spyOn(global, 'fetch');
3353+
3354+
const scope = nock(SECURITY_ALERTS_BASE_URL)
3355+
.post(ADDRESS_SCAN_ENDPOINT, {
3356+
chain: 'ethereum',
3357+
address: testAddress.toLowerCase(),
3358+
})
3359+
.reply(200, mockResponse);
3360+
3361+
const result1 = await controller.scanAddress(testChainId, testAddress);
3362+
expect(result1).toMatchObject(mockResponse);
3363+
3364+
const result2 = await controller.scanAddress(testChainId, testAddress);
3365+
expect(result2).toMatchObject(mockResponse);
3366+
3367+
expect(fetchSpy).toHaveBeenCalledTimes(1);
3368+
expect(scope.isDone()).toBe(true);
3369+
3370+
fetchSpy.mockRestore();
3371+
});
3372+
3373+
it('will cache addresses per chain ID', async () => {
3374+
const chainId1 = '0x1';
3375+
const chainId2 = '0x89';
3376+
3377+
const mockResponse1: AddressScanResult = {
3378+
result_type: AddressScanResultType.Benign,
3379+
label: 'ethereum result',
3380+
};
3381+
3382+
const mockResponse2: AddressScanResult = {
3383+
result_type: AddressScanResultType.Warning,
3384+
label: 'polygon result',
3385+
};
3386+
3387+
const scope1 = nock(SECURITY_ALERTS_BASE_URL)
3388+
.post(ADDRESS_SCAN_ENDPOINT, {
3389+
chain: 'ethereum',
3390+
address: testAddress.toLowerCase(),
3391+
})
3392+
.reply(200, mockResponse1);
3393+
3394+
const scope2 = nock(SECURITY_ALERTS_BASE_URL)
3395+
.post(ADDRESS_SCAN_ENDPOINT, {
3396+
chain: 'polygon',
3397+
address: testAddress.toLowerCase(),
3398+
})
3399+
.reply(200, mockResponse2);
3400+
3401+
const result1 = await controller.scanAddress(chainId1, testAddress);
3402+
const result2 = await controller.scanAddress(chainId2, testAddress);
3403+
3404+
expect(result1).toMatchObject(mockResponse1);
3405+
expect(result2).toMatchObject(mockResponse2);
3406+
expect(scope1.isDone()).toBe(true);
3407+
expect(scope2.isDone()).toBe(true);
3408+
3409+
const cachedResult1 = await controller.scanAddress(chainId1, testAddress);
3410+
const cachedResult2 = await controller.scanAddress(chainId2, testAddress);
3411+
3412+
expect(cachedResult1).toMatchObject(mockResponse1);
3413+
expect(cachedResult2).toMatchObject(mockResponse2);
3414+
});
3415+
});
32113416
});
32123417

32133418
describe('URL Scan Cache', () => {
@@ -3571,6 +3776,7 @@ describe('URL Scan Cache', () => {
35713776
),
35723777
).toMatchInlineSnapshot(`
35733778
Object {
3779+
"addressScanCache": Object {},
35743780
"c2DomainBlocklistLastFetched": 0,
35753781
"hotlistLastFetched": 0,
35763782
"phishingLists": Array [],
@@ -3594,6 +3800,7 @@ describe('URL Scan Cache', () => {
35943800
),
35953801
).toMatchInlineSnapshot(`
35963802
Object {
3803+
"addressScanCache": Object {},
35973804
"tokenScanCache": Object {},
35983805
"urlScanCache": Object {},
35993806
}

0 commit comments

Comments
 (0)