diff --git a/README.md b/README.md index a44a28354aa..64a23005665 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,7 @@ linkStyle default opacity:0.5 perps_controller --> profile_sync_controller; perps_controller --> remote_feature_flag_controller; perps_controller --> transaction_controller; + phishing_controller --> address_book_controller; phishing_controller --> base_controller; phishing_controller --> controller_utils; phishing_controller --> messenger; diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cef941a545f..220de128920 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1634,26 +1634,15 @@ "count": 2 } }, - "packages/phishing-controller/src/PhishingController.test.ts": { - "jest/unbound-method": { - "count": 7 - }, - "no-restricted-syntax": { - "count": 1 - } - }, "packages/phishing-controller/src/PhishingController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 19 + "count": 14 }, "@typescript-eslint/naming-convention": { "count": 1 }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 6 - }, - "no-restricted-syntax": { - "count": 10 } }, "packages/phishing-controller/src/PhishingDetector.test.ts": { diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index d5e3df80c6a..4e964c0a0ce 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `findSimilarAddresses` utility and `PhishingController:checkAddressPoisoning` messenger action to detect address poisoning attempts against known recipients ([#8171](https://github.com/MetaMask/core/pull/8171)) + - The controller now hydrates and maintains a set of known recipient addresses from confirmed transactions (`TransactionController`) and the address book (`AddressBookController`) + - Exposes match metadata including prefix/suffix match lengths, poisoning score, and diff indices +- Add `@metamask/address-book-controller` as a dependency ([#8171](https://github.com/MetaMask/core/pull/8171)) - Support path-based phishing lists (`blocklistPaths`, `whitelistPaths`) and path-aware URL scanning for shared gateways (for example IPFS gateways and `sites.google.com`) via `getPhishingDetectionScanUrlParam`, `isPhishingDetectionPathBasedHostname`, and `PHISHING_DETECTION_PATH_BASED_ROOT_DOMAINS` ([#8662](https://github.com/MetaMask/core/pull/8662)) ### Changed diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 00caa198c34..40105f1760c 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -53,6 +53,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/address-book-controller": "^7.1.2", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.1.0", "@metamask/messenger": "^1.2.0", diff --git a/packages/phishing-controller/src/PhishingController-method-action-types.ts b/packages/phishing-controller/src/PhishingController-method-action-types.ts index ed5d94cd2c5..59e7070b312 100644 --- a/packages/phishing-controller/src/PhishingController-method-action-types.ts +++ b/packages/phishing-controller/src/PhishingController-method-action-types.ts @@ -5,6 +5,17 @@ import type { PhishingController } from './PhishingController'; +/** + * Finds known recipient addresses that look like an address poisoning match. + * + * @param candidate - The recipient address being checked. + * @returns Similar known recipient matches sorted by score. + */ +export type PhishingControllerCheckAddressPoisoningAction = { + type: `PhishingController:checkAddressPoisoning`; + handler: PhishingController['checkAddressPoisoning']; +}; + /** * Conditionally update the phishing configuration. * @@ -121,6 +132,7 @@ export type PhishingControllerGetApprovalsAction = { * Union of all PhishingController action types. */ export type PhishingControllerMethodActions = + | PhishingControllerCheckAddressPoisoningAction | PhishingControllerMaybeUpdateStateAction | PhishingControllerTestOriginAction | PhishingControllerIsBlockedRequestAction diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 9e931217d7e..4d49d8c41aa 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -1,3 +1,4 @@ +import type { AddressBookControllerState } from '@metamask/address-book-controller'; import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -5,6 +6,8 @@ import type { MessengerEvents, MockAnyNamespace, } from '@metamask/messenger'; +import { TransactionStatus } from '@metamask/transaction-controller'; +import type { TransactionControllerState } from '@metamask/transaction-controller'; import { strict as assert } from 'assert'; import nock, { cleanAll, isDone, pendingMocks } from 'nock'; @@ -58,6 +61,27 @@ type RootMessenger = Messenger< RootMessenger >; +type SetupMessengerOptions = { + transactionControllerState?: TransactionControllerState; + addressBookControllerState?: AddressBookControllerState; +}; + +function getDefaultTransactionControllerState(): TransactionControllerState { + return { + transactions: [], + transactionBatches: [], + methodData: {}, + lastFetchedBlockNumbers: {}, + submitHistory: [], + }; +} + +function getDefaultAddressBookControllerState(): AddressBookControllerState { + return { + addressBook: {}, + }; +} + /** * Creates and returns a root messenger for testing * @@ -72,13 +96,26 @@ function getRootMessenger(): RootMessenger { /** * Constructs a messenger for use in PhishingController tests. * + * @param options - Options for the test messenger. + * @param options.transactionControllerState - Initial transaction controller state. + * @param options.addressBookControllerState - Initial address book controller state. * @returns A messenger and the root messenger. */ -function setupMessenger(): { +function setupMessenger(options: SetupMessengerOptions = {}): { messenger: PhishingControllerMessenger; rootMessenger: RootMessenger; + setTransactionControllerState: (state: TransactionControllerState) => void; + setAddressBookControllerState: (state: AddressBookControllerState) => void; } { + const { + transactionControllerState: + initialTransactionControllerState = getDefaultTransactionControllerState(), + addressBookControllerState: + initialAddressBookControllerState = getDefaultAddressBookControllerState(), + } = options; const rootMessenger = getRootMessenger(); + let transactionControllerState = initialTransactionControllerState; + let addressBookControllerState = initialAddressBookControllerState; const messenger = new Messenger< typeof controllerName, @@ -91,14 +128,41 @@ function setupMessenger(): { }); rootMessenger.delegate({ - actions: [], - events: ['TransactionController:stateChange'], + actions: [ + 'AddressBookController:getState', + 'TransactionController:getState', + ], + events: [ + // eslint-disable-next-line no-restricted-syntax + 'AddressBookController:stateChange', + // eslint-disable-next-line no-restricted-syntax + 'TransactionController:stateChange', + ], messenger, }); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => transactionControllerState, + ); + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => addressBookControllerState, + ); + return { messenger, rootMessenger, + setTransactionControllerState: ( + state: TransactionControllerState, + ): void => { + transactionControllerState = state; + }, + setAddressBookControllerState: ( + state: AddressBookControllerState, + ): void => { + addressBookControllerState = state; + }, }; } @@ -4267,7 +4331,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).toHaveBeenCalledWith({ chainId: mockTransaction.chainId.toLowerCase(), @@ -4296,7 +4360,7 @@ describe('Transaction Controller State Change Integration', () => { }, ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).toHaveBeenCalledWith({ chainId: mockTransaction.chainId.toLowerCase(), @@ -4326,7 +4390,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -4348,7 +4412,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -4370,7 +4434,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -4392,7 +4456,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error processing transaction state change:', @@ -4425,7 +4489,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(consoleErrorSpy).toHaveBeenCalledWith( 'Error scanning tokens for chain 0x1:', @@ -4434,4 +4498,671 @@ describe('Transaction Controller State Change Integration', () => { consoleErrorSpy.mockRestore(); }); + + it('continues bulk token scanning if known recipient updates fail', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + const mockTransaction = createMockTransaction('test-tx-1', [ + TEST_ADDRESSES.USDC, + TEST_ADDRESSES.MOCK_TOKEN_1, + ]); + + globalMessenger.publish( + 'TransactionController:stateChange', + { + ...createMockStateChangePayload([mockTransaction]), + transactions: undefined, + } as unknown as TransactionControllerState, + [ + { + op: 'replace' as const, + path: ['transactions'], + value: undefined, + }, + { + op: 'add' as const, + path: ['transactions', 0], + value: mockTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error updating known recipients from transaction state:', + expect.any(Error), + ); + expect(bulkScanTokensSpy).toHaveBeenCalledWith({ + chainId: mockTransaction.chainId.toLowerCase(), + tokens: [ + TEST_ADDRESSES.USDC.toLowerCase(), + TEST_ADDRESSES.MOCK_TOKEN_1.toLowerCase(), + ], + }); + + consoleErrorSpy.mockRestore(); + }); +}); + +describe('Address poisoning detection', () => { + const ADDRESS_BOOK_RECIPIENT = + '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5678' as `0x${string}`; + const CONFIRMED_TX_RECIPIENT = + '0x1234cccccccccccccccccccccccccccccccc9abc' as `0x${string}`; + const CANDIDATE_ADDRESS = + '0x1234aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678' as `0x${string}`; + const TX_CANDIDATE_ADDRESS = + '0x1234aaaacccccccccccccccccccccccccccc9abc' as `0x${string}`; + + it('hydrates known recipients from confirmed transactions and address book state', () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + const { messenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + addressBookControllerState: { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('ignores non-confirmed transactions when hydrating known recipients', () => { + const { messenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [ + createMockTransaction('unapproved-tx', [], { + status: TransactionStatus.unapproved, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }), + ], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('updates known recipients when address book state changes', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + + rootMessenger.publish('AddressBookController:stateChange', { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('updates known recipients when confirmed transactions change', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'add' as const, + path: ['transactions', 0], + value: confirmedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('updates transaction recipients when a confirmed transaction recipient changes', async () => { + const originalTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const updatedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [originalTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([updatedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions', 0], + value: updatedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('keeps duplicate transaction recipients when one matching transaction recipient changes', async () => { + const firstTransaction = createMockTransaction('confirmed-tx-1', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const secondTransaction = createMockTransaction('confirmed-tx-2', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const updatedFirstTransaction = createMockTransaction( + 'confirmed-tx-1', + [], + { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }, + ); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [firstTransaction, secondTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([ + updatedFirstTransaction, + secondTransaction, + ]), + [ + { + op: 'replace' as const, + path: ['transactions', 0], + value: updatedFirstTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + expect( + controller.checkAddressPoisoning(TX_CANDIDATE_ADDRESS), + ).toMatchObject([ + { + knownAddress: CONFIRMED_TX_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 32, + poisoningScore: 36, + }, + ]); + }); + + it('ignores transaction state changes that do not include transaction patches', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'replace' as const, + path: ['methodData'], + value: {}, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds known recipients when the transaction collection changes', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions'], + value: [confirmedTransaction], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + }); + + it('rebuilds known recipients when a transaction patch is not indexed by array position', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + const controller = new PhishingController({ + messenger, + }); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([confirmedTransaction]), + [ + { + op: 'replace' as const, + path: ['transactions', 'confirmed-tx'], + value: confirmedTransaction, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + }); + + it('rebuilds known recipients when a remove patch does not include the removed transaction', async () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'remove' as const, + path: ['transactions', 0], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds known recipients when the transaction array length changes', async () => { + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([]), + [ + { + op: 'replace' as const, + path: ['transactions', 'length'], + value: 0, + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toStrictEqual( + [], + ); + }); + + it('rebuilds duplicate transaction recipients when transactions are removed', async () => { + const firstTransaction = createMockTransaction('confirmed-tx-1', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const secondTransaction = createMockTransaction('confirmed-tx-2', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + const { messenger, rootMessenger } = setupMessenger({ + transactionControllerState: { + ...getDefaultTransactionControllerState(), + transactions: [firstTransaction, secondTransaction], + }, + }); + + const controller = new PhishingController({ + messenger, + }); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toHaveLength(1); + + rootMessenger.publish( + 'TransactionController:stateChange', + createMockStateChangePayload([secondTransaction]), + [ + { + op: 'remove' as const, + path: ['transactions', 0], + }, + ], + ); + + await new Promise((resolve) => process.nextTick(resolve)); + + expect(controller.checkAddressPoisoning(CANDIDATE_ADDRESS)).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); + + it('logs when transaction state hydration fails', () => { + const { messenger, rootMessenger } = setupMessenger(); + const error = new Error('Transaction state unavailable'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + rootMessenger.unregisterActionHandler('TransactionController:getState'); + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => { + throw error; + }, + ); + + // eslint-disable-next-line no-new -- controller hydrates known recipients on construction + new PhishingController({ + messenger, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Unable to hydrate known recipients from TransactionController state; address poisoning checks will not include existing confirmed transactions.', + error, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('logs when address book state hydration fails', () => { + const { messenger, rootMessenger } = setupMessenger(); + const error = new Error('Address book state unavailable'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + + rootMessenger.unregisterActionHandler('AddressBookController:getState'); + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => { + throw error; + }, + ); + + // eslint-disable-next-line no-new -- controller hydrates known recipients on construction + new PhishingController({ + messenger, + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Unable to hydrate known recipients from AddressBookController state; address poisoning checks will not include existing address book entries.', + error, + ); + + consoleErrorSpy.mockRestore(); + }); + + it('exposes checkAddressPoisoning through the controller messenger', async () => { + const { messenger, rootMessenger } = setupMessenger({ + addressBookControllerState: { + addressBook: { + '0x1': { + [ADDRESS_BOOK_RECIPIENT]: { + address: ADDRESS_BOOK_RECIPIENT, + name: 'Known recipient', + chainId: '0x1', + memo: '', + isEns: false, + }, + }, + }, + }, + }); + + // eslint-disable-next-line no-new -- controller registers messenger handlers as a side effect + new PhishingController({ + messenger, + }); + + expect( + rootMessenger.call( + 'PhishingController:checkAddressPoisoning', + CANDIDATE_ADDRESS, + ), + ).toMatchObject([ + { + knownAddress: ADDRESS_BOOK_RECIPIENT, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + }, + ]); + }); }); diff --git a/packages/phishing-controller/src/PhishingController.ts b/packages/phishing-controller/src/PhishingController.ts index 2619313124b..9131ba12d20 100644 --- a/packages/phishing-controller/src/PhishingController.ts +++ b/packages/phishing-controller/src/PhishingController.ts @@ -1,3 +1,8 @@ +import type { + AddressBookControllerGetStateAction, + AddressBookControllerState, + AddressBookControllerStateChangeEvent, +} from '@metamask/address-book-controller'; import { BaseController } from '@metamask/base-controller'; import type { StateMetadata, @@ -5,17 +10,22 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import { + isValidHexAddress, safelyExecute, safelyExecuteWithTimeout, } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { + TransactionControllerGetStateAction, + TransactionControllerState, TransactionControllerStateChangeEvent, TransactionMeta, } from '@metamask/transaction-controller'; +import { TransactionStatus } from '@metamask/transaction-controller'; import type { Patch } from 'immer'; import { toASCII } from 'punycode/punycode.js'; +import { findSimilarAddresses } from './address-poisoning'; import { CacheManager } from './CacheManager'; import type { CacheEntry } from './CacheManager'; import { convertListToTrie, insertToTrie, matchedPathPrefix } from './PathTrie'; @@ -40,6 +50,7 @@ import type { TokenScanApiResponse, AddressScanCacheData, AddressScanResult, + SimilarAddressMatch, ApprovalsResponse, } from './types'; import { @@ -393,6 +404,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'bulkScanTokens', 'scanAddress', 'getApprovals', + 'checkAddressPoisoning', ] as const; /** @@ -424,12 +436,16 @@ export type PhishingControllerEvents = PhishingControllerStateChangeEvent; /** * The external actions available to the PhishingController. */ -type AllowedActions = never; +type AllowedActions = + | AddressBookControllerGetStateAction + | TransactionControllerGetStateAction; /** * The external events available to the PhishingController. */ -export type AllowedEvents = TransactionControllerStateChangeEvent; +export type AllowedEvents = + | AddressBookControllerStateChangeEvent + | TransactionControllerStateChangeEvent; export type PhishingControllerMessenger = Messenger< typeof controllerName, @@ -474,6 +490,16 @@ export class PhishingController extends BaseController< readonly #addressScanCache: CacheManager; + readonly #knownRecipients: Set; + + readonly #transactionRecipients: Set; + + readonly #transactionRecipientsByTransactionId: Map>; + + readonly #transactionRecipientCounts: Map; + + readonly #addressBookRecipients: Set; + #inProgressHotlistUpdate?: Promise; #inProgressStalelistUpdate?: Promise; @@ -481,10 +507,14 @@ export class PhishingController extends BaseController< #isProgressC2DomainBlocklistUpdate?: Promise; readonly #transactionControllerStateChangeHandler: ( - state: { transactions: TransactionMeta[] }, + state: TransactionControllerState, patches: Patch[], ) => void; + readonly #addressBookControllerStateChangeHandler: ( + state: AddressBookControllerState, + ) => void; + /** * Construct a Phishing Controller. * @@ -527,8 +557,15 @@ export class PhishingController extends BaseController< this.#stalelistRefreshInterval = stalelistRefreshInterval; this.#hotlistRefreshInterval = hotlistRefreshInterval; this.#c2DomainBlocklistRefreshInterval = c2DomainBlocklistRefreshInterval; + this.#knownRecipients = new Set(); + this.#transactionRecipients = new Set(); + this.#transactionRecipientsByTransactionId = new Map(); + this.#transactionRecipientCounts = new Map(); + this.#addressBookRecipients = new Set(); this.#transactionControllerStateChangeHandler = this.#onTransactionControllerStateChange.bind(this); + this.#addressBookControllerStateChangeHandler = + this.#onAddressBookControllerStateChange.bind(this); this.#urlScanCache = new CacheManager({ cacheTTL: urlScanCacheTTL, maxCacheSize: urlScanCacheMaxSize, @@ -566,11 +603,22 @@ export class PhishingController extends BaseController< ); this.updatePhishingDetector(); + this.#hydrateKnownRecipients(); + this.#subscribeToAddressBookControllerStateChange(); this.#subscribeToTransactionControllerStateChange(); } - #subscribeToTransactionControllerStateChange() { + #subscribeToAddressBookControllerStateChange(): void { + this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax + 'AddressBookController:stateChange', + this.#addressBookControllerStateChangeHandler, + ); + } + + #subscribeToTransactionControllerStateChange(): void { this.messenger.subscribe( + // eslint-disable-next-line no-restricted-syntax 'TransactionController:stateChange', this.#transactionControllerStateChangeHandler, ); @@ -616,10 +664,19 @@ export class PhishingController extends BaseController< * @param patches - Array of Immer patches only for transaction-level changes */ #onTransactionControllerStateChange( - _state: { transactions: TransactionMeta[] }, + _state: TransactionControllerState, patches: Patch[], - ) { + ): void { try { + try { + this.#updateKnownRecipientsFromTransactionPatches(_state, patches); + } catch (error) { + console.error( + 'Error updating known recipients from transaction state:', + error, + ); + } + const tokensByChain = new Map>(); for (const patch of patches) { @@ -644,6 +701,10 @@ export class PhishingController extends BaseController< } } + #onAddressBookControllerStateChange(state: AddressBookControllerState): void { + this.#setKnownRecipientsFromAddressBookState(state); + } + /** * Collect token addresses from a transaction and group them by chain * @@ -653,7 +714,7 @@ export class PhishingController extends BaseController< #getTokensFromTransaction( transaction: TransactionMeta, tokensByChain: Map>, - ) { + ): void { // extract token addresses from simulation data const tokenAddresses = transaction.simulationData?.tokenBalanceChanges?.map( (tokenChange) => tokenChange.address.toLowerCase(), @@ -681,7 +742,7 @@ export class PhishingController extends BaseController< * * @param tokensByChain - Map of chainId to token addresses */ - #scanTokensByChain(tokensByChain: Map>) { + #scanTokensByChain(tokensByChain: Map>): void { for (const [chainId, tokenSet] of tokensByChain) { if (tokenSet.size > 0) { const tokens = Array.from(tokenSet); @@ -695,13 +756,244 @@ export class PhishingController extends BaseController< } } + #hydrateKnownRecipients(): void { + this.#hydrateKnownRecipientsFromTransactionState(); + this.#hydrateKnownRecipientsFromAddressBookState(); + } + + #hydrateKnownRecipientsFromTransactionState(): void { + try { + const state = this.messenger.call('TransactionController:getState'); + this.#setKnownRecipientsFromTransactionState(state); + } catch (error) { + console.error( + 'Unable to hydrate known recipients from TransactionController state; address poisoning checks will not include existing confirmed transactions.', + error, + ); + } + } + + #hydrateKnownRecipientsFromAddressBookState(): void { + try { + const state = this.messenger.call('AddressBookController:getState'); + this.#setKnownRecipientsFromAddressBookState(state); + } catch (error) { + console.error( + 'Unable to hydrate known recipients from AddressBookController state; address poisoning checks will not include existing address book entries.', + error, + ); + } + } + + #setKnownRecipientsFromTransactionState( + state: TransactionControllerState, + ): void { + this.#transactionRecipients.clear(); + this.#transactionRecipientsByTransactionId.clear(); + this.#transactionRecipientCounts.clear(); + + for (const transaction of state.transactions) { + this.#addTransactionRecipients(transaction); + } + + this.#rebuildKnownRecipients(); + } + + #updateKnownRecipientsFromTransactionPatches( + state: TransactionControllerState, + patches: Patch[], + ): void { + let recipientsChanged = false; + + for (const patch of patches) { + if (patch.path[0] !== 'transactions') { + continue; + } + + if (patch.path.length === 1) { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + const transactionIndex = patch.path[1]; + + if (transactionIndex === 'length') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + if (patch.op === 'remove') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + if (typeof transactionIndex !== 'number') { + this.#setKnownRecipientsFromTransactionState(state); + return; + } + + const transaction = + this.#getTransactionFromPatchValue(patch.value) ?? + state.transactions[transactionIndex]; + + if (!transaction) { + continue; + } + + recipientsChanged = + this.#updateTransactionRecipients(transaction) || recipientsChanged; + } + + if (recipientsChanged) { + this.#rebuildKnownRecipients(); + } + } + + #getTransactionFromPatchValue(value: unknown): TransactionMeta | undefined { + const transaction = value as Partial; + + if ( + value && + typeof value === 'object' && + typeof transaction.id === 'string' && + transaction.txParams !== undefined + ) { + return value as TransactionMeta; + } + + return undefined; + } + + #updateTransactionRecipients(transaction: TransactionMeta): boolean { + const recipientsRemoved = this.#removeTransactionRecipients(transaction.id); + const recipientsAdded = this.#addTransactionRecipients(transaction); + + return recipientsRemoved || recipientsAdded; + } + + #addTransactionRecipients(transaction: TransactionMeta): boolean { + const recipients = this.#getRecipientAddressesFromTransaction(transaction); + + if (recipients.length === 0) { + return false; + } + + this.#transactionRecipientsByTransactionId.set( + transaction.id, + new Set(recipients), + ); + + for (const address of recipients) { + const count = this.#transactionRecipientCounts.get(address) ?? 0; + this.#transactionRecipientCounts.set(address, count + 1); + this.#transactionRecipients.add(address); + } + + return true; + } + + #removeTransactionRecipients(transactionId: string): boolean { + const recipients = + this.#transactionRecipientsByTransactionId.get(transactionId); + + if (!recipients) { + return false; + } + + this.#transactionRecipientsByTransactionId.delete(transactionId); + + for (const address of recipients) { + const count = this.#transactionRecipientCounts.get(address) as number; + + if (count <= 1) { + this.#transactionRecipientCounts.delete(address); + this.#transactionRecipients.delete(address); + } else { + this.#transactionRecipientCounts.set(address, count - 1); + } + } + + return true; + } + + #setKnownRecipientsFromAddressBookState( + state: AddressBookControllerState, + ): void { + this.#addressBookRecipients.clear(); + for (const address of this.#getAddressBookRecipients(state)) { + this.#addressBookRecipients.add(address); + } + this.#rebuildKnownRecipients(); + } + + #rebuildKnownRecipients(): void { + this.#knownRecipients.clear(); + + for (const address of this.#transactionRecipients) { + this.#knownRecipients.add(address); + } + + for (const address of this.#addressBookRecipients) { + this.#knownRecipients.add(address); + } + } + + #getAddressBookRecipients(state: AddressBookControllerState): Set { + return new Set( + Object.values(state.addressBook) + .flatMap((entriesByAddress) => Object.values(entriesByAddress)) + .map((entry) => entry.address.toLowerCase()), + ); + } + + #getRecipientAddressesFromTransaction( + transaction: TransactionMeta, + ): string[] { + if (transaction.status !== TransactionStatus.confirmed) { + return []; + } + + const transactionRecipient = this.#normalizeAddress( + transaction.txParams.to, + ); + const swapAndSendRecipient = this.#normalizeAddress( + transaction.swapAndSendRecipient, + ); + + return Array.from( + new Set( + [transactionRecipient, swapAndSendRecipient].filter( + (address): address is string => Boolean(address), + ), + ), + ); + } + + #normalizeAddress(address?: string | null): string | null { + if (!address || !isValidHexAddress(address, { allowNonPrefixed: false })) { + return null; + } + + return address.toLowerCase(); + } + /** * Updates this.detector with an instance of PhishingDetector using the current state. */ - updatePhishingDetector() { + updatePhishingDetector(): void { this.#detector = new PhishingDetector(this.state.phishingLists); } + /** + * Finds known recipient addresses that look like an address poisoning match. + * + * @param candidate - The recipient address being checked. + * @returns Similar known recipient matches sorted by score. + */ + checkAddressPoisoning(candidate: string): SimilarAddressMatch[] { + return findSimilarAddresses(candidate, Array.from(this.#knownRecipients)); + } + /** * Determine if an update to the stalelist configuration is needed. * @@ -965,17 +1257,18 @@ export class PhishingController extends BaseController< recommendedAction: RecommendedAction.None, fetchError: 'timeout of 8000ms exceeded', }; - } else if ('error' in apiResponse) { + } else if ((apiResponse as { error?: string }).error) { return { hostname: '', recommendedAction: RecommendedAction.None, - fetchError: apiResponse.error, + fetchError: (apiResponse as { error: string }).error, }; } + const scanResult = apiResponse as PhishingDetectionScanResult; const result = { hostname, - recommendedAction: apiResponse.recommendedAction, + recommendedAction: scanResult.recommendedAction, }; this.#urlScanCache.set(scanUrlParam, result); @@ -1136,14 +1429,13 @@ export class PhishingController extends BaseController< return null; } - if ( - 'error' in apiResponse && - 'status' in apiResponse && - 'statusText' in apiResponse - ) { - console.warn( - `Token bulk screening API error: ${apiResponse.status} ${apiResponse.statusText}`, - ); + if ((apiResponse as { error?: string }).error) { + const { status, statusText } = apiResponse as { + status: number; + statusText: string; + }; + + console.warn(`Token bulk screening API error: ${status} ${statusText}`); return null; } @@ -1221,23 +1513,24 @@ export class PhishingController extends BaseController< result_type: AddressScanResultType.ErrorResult, label: '', }; - } else if ('error' in apiResponse) { + } else if ((apiResponse as { error?: string }).error) { return { result_type: AddressScanResultType.ErrorResult, label: '', }; } + const scanResult = apiResponse as AddressScanResult; const result: AddressScanCacheData = { - result_type: apiResponse.result_type, - label: apiResponse.label, + result_type: scanResult.result_type, + label: scanResult.label, }; this.#addressScanCache.set(cacheKey, result); return { - result_type: apiResponse.result_type, - label: apiResponse.label, + result_type: scanResult.result_type, + label: scanResult.label, }; } @@ -1290,15 +1583,18 @@ export class PhishingController extends BaseController< 5000, ); + if (!apiResponse) { + return { approvals: [] }; + } + if ( - !apiResponse || - 'error' in apiResponse || - !Array.isArray(apiResponse.approvals) + (apiResponse as { error?: string }).error || + !Array.isArray((apiResponse as Partial).approvals) ) { return { approvals: [] }; } - return apiResponse; + return apiResponse as ApprovalsResponse; }; /** @@ -1441,15 +1737,16 @@ export class PhishingController extends BaseController< } // Handle HTTP error responses - if ( - 'error' in apiResponse && - 'status' in apiResponse && - 'statusText' in apiResponse - ) { + if ((apiResponse as { error?: string }).error) { + const { status, statusText } = apiResponse as { + status: number; + statusText: string; + }; + return { results: {}, errors: { - api_error: [`${apiResponse.status} ${apiResponse.statusText}`], + api_error: [`${status} ${statusText}`], }, }; } diff --git a/packages/phishing-controller/src/address-poisoning.test.ts b/packages/phishing-controller/src/address-poisoning.test.ts new file mode 100644 index 00000000000..cbf08b3ef62 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.test.ts @@ -0,0 +1,125 @@ +import { findSimilarAddresses } from './address-poisoning'; + +function getNumberRange(start: number, end: number): number[] { + return Array.from({ length: end - start + 1 }, (_, index) => start + index); +} + +describe('findSimilarAddresses', () => { + const CLASSIC_CANDIDATE = '0x1234aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678'; + const CLASSIC_KNOWN = '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5678'; + + it('returns no matches when there are no known addresses', () => { + expect(findSimilarAddresses(CLASSIC_CANDIDATE, [])).toStrictEqual([]); + }); + + it('returns a classic poisoning match with prefix, suffix, score, and diff indices', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, [CLASSIC_KNOWN]), + ).toStrictEqual([ + { + knownAddress: CLASSIC_KNOWN, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('excludes exact matches', () => { + expect( + findSimilarAddresses('0x1234567890abcdef1234567890abcdef12345678', [ + '0x1234567890abcdef1234567890abcdef12345678', + ]), + ).toStrictEqual([]); + }); + + it('matches case-insensitively', () => { + expect( + findSimilarAddresses('0x1234AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA5678', [ + CLASSIC_KNOWN, + ]), + ).toStrictEqual([ + { + knownAddress: CLASSIC_KNOWN, + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('skips partial matches below the default threshold', () => { + expect( + findSimilarAddresses('0x123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa567', [ + '0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567', + ]), + ).toStrictEqual([]); + }); + + it('supports custom thresholds', () => { + expect( + findSimilarAddresses( + '0x123aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa567', + ['0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567'], + { + prefixLen: 3, + suffixLen: 3, + }, + ), + ).toStrictEqual([ + { + knownAddress: '0x123bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb567', + prefixMatchLength: 3, + suffixMatchLength: 3, + poisoningScore: 6, + diffIndices: getNumberRange(5, 38), + }, + ]); + }); + + it('sorts multiple matches by poisoning score descending', () => { + expect( + findSimilarAddresses('0x12345aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5678', [ + '0x1234cccccccccccccccccccccccccccccccc5678', + '0x12345eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5678', + ]), + ).toStrictEqual([ + { + knownAddress: '0x12345eeeeeeeeeeeeeeeeeeeeeeeeeeeeeee5678', + prefixMatchLength: 5, + suffixMatchLength: 4, + poisoningScore: 9, + diffIndices: getNumberRange(7, 37), + }, + { + knownAddress: '0x1234cccccccccccccccccccccccccccccccc5678', + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: getNumberRange(6, 37), + }, + ]); + }); + + it('ignores non-hex candidate addresses', () => { + expect( + findSimilarAddresses('not-an-address', [CLASSIC_KNOWN]), + ).toStrictEqual([]); + }); + + it('ignores non-hex known addresses', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, ['not-an-address']), + ).toStrictEqual([]); + }); + + it('ignores differently-sized known addresses', () => { + expect( + findSimilarAddresses(CLASSIC_CANDIDATE, [ + '0x1234bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb56789', + ]), + ).toStrictEqual([]); + }); +}); diff --git a/packages/phishing-controller/src/address-poisoning.ts b/packages/phishing-controller/src/address-poisoning.ts new file mode 100644 index 00000000000..89adf2953c2 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.ts @@ -0,0 +1,102 @@ +import { isValidHexAddress } from '@metamask/controller-utils'; + +import type { SimilarAddressMatch, SimilarityOptions } from './types'; + +const DEFAULT_PREFIX_LEN = 4; +const DEFAULT_SUFFIX_LEN = 4; + +function normalizeAddress(address: string): string | null { + if (!isValidHexAddress(address, { allowNonPrefixed: false })) { + return null; + } + + return address.toLowerCase(); +} + +function getPrefixMatchLength(candidate: string, knownAddress: string): number { + let index = 0; + + while (index < candidate.length && candidate[index] === knownAddress[index]) { + index += 1; + } + + return index; +} + +function getSuffixMatchLength(candidate: string, knownAddress: string): number { + let index = 0; + + while ( + index < candidate.length && + candidate[candidate.length - 1 - index] === + knownAddress[knownAddress.length - 1 - index] + ) { + index += 1; + } + + return index; +} + +function getDiffIndices(candidate: string, knownAddress: string): number[] { + const diffIndices: number[] = []; + + for (let index = 0; index < candidate.length; index += 1) { + if (candidate[index] !== knownAddress[index]) { + diffIndices.push(index + 2); + } + } + + return diffIndices; +} + +export function findSimilarAddresses( + candidate: string, + knownAddresses: string[], + options: SimilarityOptions = {}, +): SimilarAddressMatch[] { + const normalizedCandidate = normalizeAddress(candidate); + + if (!normalizedCandidate) { + return []; + } + + const prefixLen = options.prefixLen ?? DEFAULT_PREFIX_LEN; + const suffixLen = options.suffixLen ?? DEFAULT_SUFFIX_LEN; + const candidateBody = normalizedCandidate.slice(2); + + return knownAddresses + .map((knownAddress) => { + const normalizedKnownAddress = normalizeAddress(knownAddress); + + if ( + normalizedKnownAddress?.length !== normalizedCandidate.length || + normalizedKnownAddress === normalizedCandidate + ) { + return null; + } + + const knownAddressBody = normalizedKnownAddress.slice(2); + const prefixMatchLength = getPrefixMatchLength( + candidateBody, + knownAddressBody, + ); + const suffixMatchLength = getSuffixMatchLength( + candidateBody, + knownAddressBody, + ); + + if (prefixMatchLength < prefixLen || suffixMatchLength < suffixLen) { + return null; + } + + return { + knownAddress, + prefixMatchLength, + suffixMatchLength, + poisoningScore: prefixMatchLength + suffixMatchLength, + diffIndices: getDiffIndices(candidateBody, knownAddressBody), + }; + }) + .filter((match): match is SimilarAddressMatch => Boolean(match)) + .sort((left, right) => right.poisoningScore - left.poisoningScore); +} diff --git a/packages/phishing-controller/src/index.ts b/packages/phishing-controller/src/index.ts index 5233dbad7af..bb08ca9bf5b 100644 --- a/packages/phishing-controller/src/index.ts +++ b/packages/phishing-controller/src/index.ts @@ -1,4 +1,5 @@ export * from './PhishingController'; +export { findSimilarAddresses } from './address-poisoning'; export type { LegacyPhishingDetectorList, PhishingDetectorList, @@ -11,6 +12,8 @@ export type { PhishingDetectionScanResult, AddressScanResult, BulkTokenScanResponse, + SimilarAddressMatch, + SimilarityOptions, ApprovalsResponse, Approval, Allowance, @@ -45,4 +48,5 @@ export type { PhishingControllerBulkScanTokensAction, PhishingControllerScanAddressAction, PhishingControllerGetApprovalsAction, + PhishingControllerCheckAddressPoisoningAction, } from './PhishingController-method-action-types'; diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index 5857ca36466..6fe89c79822 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -264,6 +264,47 @@ export type AddressScanCacheData = { label: string; }; +/** + * Similar address match metadata for address poisoning detection. + */ +export type SimilarAddressMatch = { + /** + * The known recipient address that resembles the candidate address. + */ + knownAddress: string; + /** + * Number of matching characters at the start of the address body. + */ + prefixMatchLength: number; + /** + * Number of matching characters at the end of the address body. + */ + suffixMatchLength: number; + /** + * Combined similarity score used to rank matches. + */ + poisoningScore: number; + /** + * Character positions where the candidate and known addresses differ. + * Indices are based on the full hex string, including the `0x` prefix. + */ + diffIndices: number[]; +}; + +/** + * Thresholds for address poisoning similarity detection. + */ +export type SimilarityOptions = { + /** + * Minimum required prefix match length. + */ + prefixLen?: number; + /** + * Minimum required suffix match length. + */ + suffixLen?: number; +}; + export const APPROVAL_SUPPORTED_CHAINS = [ 'ethereum', 'polygon', diff --git a/yarn.lock b/yarn.lock index d57ba7d5d87..e27fd3e75ea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5016,6 +5016,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: + "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0"