Skip to content
Merged
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 @@ -1299,11 +1299,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
2 changes: 2 additions & 0 deletions packages/phishing-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### 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))
- Expose missing public `PhishingController` methods through its messenger ([#8269](https://github.com/MetaMask/core/pull/8269))
- The following actions are now available:
- `PhishingController:bypass`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,11 @@ export type PhishingControllerBulkScanTokensAction = {
handler: PhishingController['bulkScanTokens'];
};

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

/**
* Union of all PhishingController action types.
*/
Expand All @@ -122,4 +127,5 @@ export type PhishingControllerMethodActions =
| PhishingControllerScanUrlAction
| PhishingControllerBulkScanUrlsAction
| PhishingControllerScanAddressAction
| PhishingControllerBulkScanTokensAction;
| PhishingControllerBulkScanTokensAction
| PhishingControllerGetApprovalsAction;
174 changes: 174 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 All @@ -38,6 +39,8 @@ import {
PhishingDetectorResultType,
RecommendedAction,
AddressScanResultType,
ApprovalResultType,
ApprovalFeatureType,
} from './types';
import { getHostnameFromUrl } from './utils';

Expand Down Expand Up @@ -3664,6 +3667,177 @@ describe('PhishingController', () => {
expect(cachedResult2).toMatchObject(mockResponse2);
});
});

describe('getApprovals', () => {
Comment thread
imblue-dabadee marked this conversation as resolved.
let rootMessenger: RootMessenger;

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: ApprovalFeatureType.Benign,
feature_id: 'VERIFIED_CONTRACT',
description: 'This contract is verified',
},
],
},
verdict: ApprovalResultType.Benign,
};
const mockResponse = { approvals: [mockApproval] };

beforeEach(() => {
const { rootMessenger: createdMessenger } = getPhishingController();
rootMessenger = createdMessenger;
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 rootMessenger.call(
'PhishingController: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 rootMessenger.call(
'PhishingController:getApprovals',
testChainId,
'',
);
expect(response).toStrictEqual({ approvals: [] });
});

it('will return empty approvals when chainId is missing', async () => {
const response = await rootMessenger.call(
'PhishingController:getApprovals',
'',
testAddress,
);
expect(response).toStrictEqual({ approvals: [] });
});

it('will return empty approvals for unknown chain ID', async () => {
const response = await rootMessenger.call(
'PhishingController:getApprovals',
'0x999999',
testAddress,
);
expect(response).toStrictEqual({ approvals: [] });
});

it('will return empty approvals for chains not supported by the approvals API', async () => {
const response = await rootMessenger.call(
'PhishingController:getApprovals',
'0x82750',
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 rootMessenger.call(
'PhishingController: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 = rootMessenger.call(
'PhishingController: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 rootMessenger.call(
'PhishingController: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 rootMessenger.call(
'PhishingController:getApprovals',
mixedCaseChainId,
testAddress,
);
expect(response).toStrictEqual(mockResponse);
expect(scope.isDone()).toBe(true);
});
});
});

describe('URL Scan Cache', () => {
Expand Down
64 changes: 64 additions & 0 deletions packages/phishing-controller/src/PhishingController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import type {
TokenScanApiResponse,
AddressScanCacheData,
AddressScanResult,
ApprovalsResponse,
} from './types';
import {
applyDiffs,
Expand All @@ -51,6 +52,7 @@ import {
splitCacheHits,
resolveChainName,
getPathnameFromUrl,
isApprovalSupportedChain,
} from './utils';

export const PHISHING_CONFIG_BASE_URL =
Expand All @@ -71,6 +73,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 = 1 * 60; // 1 minute in seconds
Expand Down Expand Up @@ -388,6 +391,7 @@ const MESSENGER_EXPOSED_METHODS = [
'bulkScanUrls',
'bulkScanTokens',
'scanAddress',
'getApprovals',
] as const;

/**
Expand Down Expand Up @@ -1233,6 +1237,66 @@ 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 (
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The flow duplicates scanAddress, seems like a good opportunity to extract the shared logic and reuse for both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed there's duplication. Similar to the test scaffold

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 || !isApprovalSupportedChain(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();
Comment thread
imblue-dabadee marked this conversation as resolved.
return data;
},
true,
5000,
);

if (
!apiResponse ||
'error' in apiResponse ||
!Array.isArray(apiResponse.approvals)
) {
return { approvals: [] };
}

return apiResponse;
};

/**
* Scan multiple tokens for malicious activity in bulk.
*
Expand Down
10 changes: 10 additions & 0 deletions packages/phishing-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,22 @@ 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';

Expand All @@ -30,4 +39,5 @@ export type {
PhishingControllerBulkScanUrlsAction,
PhishingControllerBulkScanTokensAction,
PhishingControllerScanAddressAction,
PhishingControllerGetApprovalsAction,
} from './PhishingController-method-action-types';
Loading
Loading