diff --git a/eslint-suppressions.json b/eslint-suppressions.json index bfffcbb32ef..1a4174b44a3 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1281,14 +1281,11 @@ "packages/phishing-controller/src/PhishingController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 1 - }, - "jest/unbound-method": { - "count": 7 } }, "packages/phishing-controller/src/PhishingController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 25 + "count": 20 }, "@typescript-eslint/naming-convention": { "count": 1 diff --git a/packages/phishing-controller/CHANGELOG.md b/packages/phishing-controller/CHANGELOG.md index 2ddfd9077a0..69b58567cd4 100644 --- a/packages/phishing-controller/CHANGELOG.md +++ b/packages/phishing-controller/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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)) + ### Changed - Bump `@metamask/transaction-controller` from `^62.17.0` to `^62.21.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), [#8104](https://github.com/MetaMask/core/pull/8104), [#8140](https://github.com/MetaMask/core/pull/8140)) diff --git a/packages/phishing-controller/package.json b/packages/phishing-controller/package.json index 859ebbe537c..4c85e48e20f 100644 --- a/packages/phishing-controller/package.json +++ b/packages/phishing-controller/package.json @@ -47,6 +47,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/address-book-controller": "^7.0.1", "@metamask/base-controller": "^9.0.0", "@metamask/controller-utils": "^11.19.0", "@metamask/messenger": "^0.3.0", diff --git a/packages/phishing-controller/src/PhishingController.test.ts b/packages/phishing-controller/src/PhishingController.test.ts index 9fd036ffac0..ed29471196b 100644 --- a/packages/phishing-controller/src/PhishingController.test.ts +++ b/packages/phishing-controller/src/PhishingController.test.ts @@ -5,6 +5,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'; @@ -88,8 +90,14 @@ function setupMessenger(): { }); rootMessenger.delegate({ - actions: [], - events: ['TransactionController:stateChange'], + actions: [ + 'AddressBookController:getState', + 'TransactionController:getState', + ], + events: [ + 'AddressBookController:stateChange', + 'TransactionController:stateChange', + ], messenger, }); @@ -3892,7 +3900,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(), @@ -3921,7 +3929,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(), @@ -3951,7 +3959,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -3973,7 +3981,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -3995,7 +4003,7 @@ describe('Transaction Controller State Change Integration', () => { ], ); - await new Promise(process.nextTick); + await new Promise((resolve) => process.nextTick(resolve)); expect(bulkScanTokensSpy).not.toHaveBeenCalled(); }); @@ -4017,7 +4025,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:', @@ -4050,7 +4058,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:', @@ -4059,4 +4067,318 @@ 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: '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}`; + + function getDefaultTransactionControllerState(): TransactionControllerState { + return { + transactions: [], + transactionBatches: [], + methodData: {}, + lastFetchedBlockNumbers: {}, + submitHistory: [], + }; + } + + it('hydrates known recipients from confirmed transactions and address book state', () => { + const { messenger, rootMessenger } = setupMessenger(); + + const confirmedTransaction = createMockTransaction('confirmed-tx', [], { + status: TransactionStatus.confirmed, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: CONFIRMED_TX_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + ...getDefaultTransactionControllerState(), + transactions: [confirmedTransaction], + }), + ); + + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => ({ + 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, rootMessenger } = setupMessenger(); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + ...getDefaultTransactionControllerState(), + transactions: [ + createMockTransaction('unapproved-tx', [], { + status: TransactionStatus.unapproved, + txParams: { + from: TEST_ADDRESSES.FROM_ADDRESS, + to: ADDRESS_BOOK_RECIPIENT, + value: '0x0' as `0x${string}`, + }, + }), + ], + }), + ); + + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => ({ + addressBook: {}, + }), + ); + + 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(); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + ...getDefaultTransactionControllerState(), + transactions: [], + }), + ); + + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => ({ + addressBook: {}, + }), + ); + + 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(); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + ...getDefaultTransactionControllerState(), + transactions: [], + }), + ); + + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => ({ + addressBook: {}, + }), + ); + + 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('exposes checkAddressPoisoning through the controller messenger', async () => { + const { messenger, rootMessenger } = setupMessenger(); + + rootMessenger.registerActionHandler( + 'TransactionController:getState', + () => ({ + ...getDefaultTransactionControllerState(), + transactions: [], + }), + ); + + rootMessenger.registerActionHandler( + 'AddressBookController:getState', + () => ({ + 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 6fba169f9a9..2aee5c1b3f8 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, @@ -10,12 +15,16 @@ import { } 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'; @@ -35,6 +44,7 @@ import type { TokenScanApiResponse, AddressScanCacheData, AddressScanResult, + SimilarAddressMatch, } from './types'; import { applyDiffs, @@ -399,6 +409,11 @@ export type PhishingControllerScanAddressAction = { handler: PhishingController['scanAddress']; }; +export type PhishingControllerCheckAddressPoisoningAction = { + type: `${typeof controllerName}:checkAddressPoisoning`; + handler: PhishingController['checkAddressPoisoning']; +}; + export type PhishingControllerGetStateAction = ControllerGetStateAction< typeof controllerName, PhishingControllerState @@ -410,7 +425,8 @@ export type PhishingControllerActions = | TestOrigin | PhishingControllerBulkScanUrlsAction | PhishingControllerBulkScanTokensAction - | PhishingControllerScanAddressAction; + | PhishingControllerScanAddressAction + | PhishingControllerCheckAddressPoisoningAction; export type PhishingControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -422,12 +438,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, @@ -472,6 +492,12 @@ export class PhishingController extends BaseController< readonly #addressScanCache: CacheManager; + readonly #knownRecipients: Set; + + readonly #transactionRecipients: Set; + + readonly #addressBookRecipients: Set; + #inProgressHotlistUpdate?: Promise; #inProgressStalelistUpdate?: Promise; @@ -479,10 +505,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. * @@ -525,8 +555,13 @@ export class PhishingController extends BaseController< this.#stalelistRefreshInterval = stalelistRefreshInterval; this.#hotlistRefreshInterval = hotlistRefreshInterval; this.#c2DomainBlocklistRefreshInterval = c2DomainBlocklistRefreshInterval; + this.#knownRecipients = new Set(); + this.#transactionRecipients = new Set(); + this.#addressBookRecipients = new Set(); this.#transactionControllerStateChangeHandler = this.#onTransactionControllerStateChange.bind(this); + this.#addressBookControllerStateChangeHandler = + this.#onAddressBookControllerStateChange.bind(this); this.#urlScanCache = new CacheManager({ cacheTTL: urlScanCacheTTL, maxCacheSize: urlScanCacheMaxSize, @@ -561,10 +596,19 @@ export class PhishingController extends BaseController< this.#registerMessageHandlers(); this.updatePhishingDetector(); + this.#hydrateKnownRecipients(); + this.#subscribeToAddressBookControllerStateChange(); this.#subscribeToTransactionControllerStateChange(); } - #subscribeToTransactionControllerStateChange() { + #subscribeToAddressBookControllerStateChange(): void { + this.messenger.subscribe( + 'AddressBookController:stateChange', + this.#addressBookControllerStateChangeHandler, + ); + } + + #subscribeToTransactionControllerStateChange(): void { this.messenger.subscribe( 'TransactionController:stateChange', this.#transactionControllerStateChangeHandler, @@ -600,6 +644,11 @@ export class PhishingController extends BaseController< `${controllerName}:scanAddress` as const, this.scanAddress.bind(this), ); + + this.messenger.registerActionHandler( + `${controllerName}:checkAddressPoisoning` as const, + this.checkAddressPoisoning.bind(this), + ); } /** @@ -642,10 +691,21 @@ 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 { + if (patches.some((patch) => patch.path[0] === 'transactions')) { + try { + this.#setKnownRecipientsFromTransactionState(_state); + } catch (error) { + console.error( + 'Error updating known recipients from transaction state:', + error, + ); + } + } + const tokensByChain = new Map>(); for (const patch of patches) { @@ -670,6 +730,10 @@ export class PhishingController extends BaseController< } } + #onAddressBookControllerStateChange(state: AddressBookControllerState): void { + this.#setKnownRecipientsFromAddressBookState(state); + } + /** * Collect token addresses from a transaction and group them by chain * @@ -679,7 +743,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(), @@ -707,7 +771,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); @@ -721,13 +785,133 @@ 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 { + // Some environments may not provide transaction state hydration. + } + } + + #hydrateKnownRecipientsFromAddressBookState(): void { + try { + const state = this.messenger.call('AddressBookController:getState'); + this.#setKnownRecipientsFromAddressBookState(state); + } catch { + // Some environments may not provide address book state hydration. + } + } + + #setKnownRecipientsFromTransactionState( + state: TransactionControllerState, + ): void { + this.#transactionRecipients.clear(); + for (const address of this.#getConfirmedTransactionRecipients( + state.transactions, + )) { + this.#transactionRecipients.add(address); + } + this.#rebuildKnownRecipients(); + } + + #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); + } + } + + #getConfirmedTransactionRecipients( + transactions: TransactionMeta[], + ): Set { + return new Set( + transactions.flatMap((transaction) => + this.#getRecipientAddressesFromTransaction(transaction), + ), + ); + } + + #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 transferRecipient = this.#normalizeAddress( + ( + transaction.transferInformation as + | { recipientAddress?: string } + | undefined + )?.recipientAddress, + ); + + return Array.from( + new Set( + [transactionRecipient, transferRecipient].filter( + (address): address is string => Boolean(address), + ), + ), + ); + } + + #normalizeAddress(address?: string | null): string | null { + if (!address || !/^0x[0-9a-fA-F]{40}$/u.test(address)) { + 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)); + } + /** * Set the interval at which the stale phishing list will be refetched. * Fetching will only occur on the next call to test/bypass. 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..2b4e5d46928 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.test.ts @@ -0,0 +1,93 @@ +import { findSimilarAddresses } from './address-poisoning'; + +describe('findSimilarAddresses', () => { + it('returns a classic poisoning match with prefix, suffix, score, and diff indices', () => { + expect( + findSimilarAddresses('0x1234aaaa5678', ['0x1234bbbb5678']), + ).toStrictEqual([ + { + knownAddress: '0x1234bbbb5678', + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: [6, 7, 8, 9], + }, + ]); + }); + + it('excludes exact matches', () => { + expect( + findSimilarAddresses('0x1234567890abcdef', ['0x1234567890abcdef']), + ).toStrictEqual([]); + }); + + it('matches case-insensitively', () => { + expect( + findSimilarAddresses('0xABCDaaaaEF12', ['0xabcdBBBBef12']), + ).toStrictEqual([ + { + knownAddress: '0xabcdBBBBef12', + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: [6, 7, 8, 9], + }, + ]); + }); + + it('skips partial matches below the default threshold', () => { + expect( + findSimilarAddresses('0x123aaaa567', ['0x123bbbb567']), + ).toStrictEqual([]); + }); + + it('supports custom thresholds', () => { + expect( + findSimilarAddresses('0x123aaaa567', ['0x123bbbb567'], { + prefixLen: 3, + suffixLen: 3, + }), + ).toStrictEqual([ + { + knownAddress: '0x123bbbb567', + prefixMatchLength: 3, + suffixMatchLength: 3, + poisoningScore: 6, + diffIndices: [5, 6, 7, 8], + }, + ]); + }); + + it('sorts multiple matches by poisoning score descending', () => { + expect( + findSimilarAddresses('0x12345aaabbbb5678', [ + '0x1234ccccdddd5678', + '0x12345eeeefaa5678', + ]), + ).toStrictEqual([ + { + knownAddress: '0x12345eeeefaa5678', + prefixMatchLength: 5, + suffixMatchLength: 4, + poisoningScore: 9, + diffIndices: [7, 8, 9, 10, 11, 12, 13], + }, + { + knownAddress: '0x1234ccccdddd5678', + prefixMatchLength: 4, + suffixMatchLength: 4, + poisoningScore: 8, + diffIndices: [6, 7, 8, 9, 10, 11, 12, 13], + }, + ]); + }); + + it('ignores non-hex and differently-sized addresses', () => { + expect( + findSimilarAddresses('not-an-address', [ + '0x1234bbbb5678', + '0x1234bbbb56789', + ]), + ).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..ed3942ccbe6 --- /dev/null +++ b/packages/phishing-controller/src/address-poisoning.ts @@ -0,0 +1,104 @@ +import type { SimilarAddressMatch, SimilarityOptions } from './types'; + +const DEFAULT_PREFIX_LEN = 4; +const DEFAULT_SUFFIX_LEN = 4; + +function isHexAddress(address: string): address is `0x${string}` { + return /^0x[0-9a-fA-F]+$/u.test(address); +} + +function normalizeAddress(address: string): string | null { + if (!isHexAddress(address)) { + 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 33b89b6e06c..5e90dfcd849 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, } from './types'; export type { TokenScanCacheData } from './types'; export { TokenScanResultType } from './types'; diff --git a/packages/phishing-controller/src/types.ts b/packages/phishing-controller/src/types.ts index 169885ecfdd..ea5014ba5e0 100644 --- a/packages/phishing-controller/src/types.ts +++ b/packages/phishing-controller/src/types.ts @@ -262,3 +262,44 @@ export type AddressScanCacheData = { result_type: AddressScanResultType; 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; +}; diff --git a/yarn.lock b/yarn.lock index b11d8c0e778..749bb86e537 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4714,6 +4714,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/phishing-controller@workspace:packages/phishing-controller" dependencies: + "@metamask/address-book-controller": "npm:^7.0.1" "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" "@metamask/controller-utils": "npm:^11.19.0"