diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md index 69fdef8341..6f0440ea88 100644 --- a/packages/compliance-controller/CHANGELOG.md +++ b/packages/compliance-controller/CHANGELOG.md @@ -7,10 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ComplianceService` support for an explicit Compliance API URL ([#8820](https://github.com/MetaMask/core/pull/8820)). +- Add `selectAreAnyWalletsBlocked` ([#8820](https://github.com/MetaMask/core/pull/8820)). + ### Changed - Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) +### Fixed + +- Match EVM address casing consistently when reading cached wallet compliance statuses ([#8820](https://github.com/MetaMask/core/pull/8820)). + ## [2.0.1] ### Changed diff --git a/packages/compliance-controller/README.md b/packages/compliance-controller/README.md index 57c20d1962..641e060e86 100644 --- a/packages/compliance-controller/README.md +++ b/packages/compliance-controller/README.md @@ -7,7 +7,7 @@ Manages OFAC compliance checks for wallet addresses by interfacing with the Comp This package provides: - **`ComplianceService`** — A data service that communicates with the Compliance API to check whether wallet addresses are sanctioned under OFAC regulations. -- **`ComplianceController`** — A controller that manages compliance state, caching wallet compliance results and blocked wallet lists. +- **`ComplianceController`** — A controller that manages compliance state, caching wallet compliance results. ## Installation @@ -47,7 +47,7 @@ const serviceMessenger = new Messenger({ new ComplianceService({ messenger: serviceMessenger, fetch, - env: 'production', + apiUrl: 'https://compliance.api.cx.metamask.io', }); // Create controller messenger and controller @@ -70,9 +70,6 @@ await rootMessenger.call('ComplianceController:checkWalletsCompliance', [ '0x1234...', '0x5678...', ]); - -// Fetch the full blocked wallets list -await rootMessenger.call('ComplianceController:updateBlockedWallets'); ``` ## Contributing diff --git a/packages/compliance-controller/src/ComplianceController.test.ts b/packages/compliance-controller/src/ComplianceController.test.ts index 687bcc376b..96e7d9d950 100644 --- a/packages/compliance-controller/src/ComplianceController.test.ts +++ b/packages/compliance-controller/src/ComplianceController.test.ts @@ -1,4 +1,5 @@ import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { toChecksumHexAddress } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -8,7 +9,10 @@ import type { import { ComplianceController } from './ComplianceController'; import type { ComplianceControllerMessenger } from './ComplianceController'; -import { selectIsWalletBlocked } from './selectors'; +import { selectAreAnyWalletsBlocked, selectIsWalletBlocked } from './selectors'; + +const LOWERCASE_EVM_ADDRESS = '0x4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; +const CHECKSUM_EVM_ADDRESS = toChecksumHexAddress(LOWERCASE_EVM_ADDRESS); describe('ComplianceController', () => { describe('constructor', () => { @@ -96,6 +100,88 @@ describe('ComplianceController', () => { }, ); }); + + it('returns true for an EVM address with different casing than the cached key', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + [LOWERCASE_EVM_ADDRESS]: { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect( + selectIsWalletBlocked(CHECKSUM_EVM_ADDRESS)(controller.state), + ).toBe(true); + }, + ); + }); + + it('does not use case-insensitive matching for non-EVM addresses', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + SolanaAddress: { + address: 'SolanaAddress', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect(selectIsWalletBlocked('solanaaddress')(controller.state)).toBe( + false, + ); + }, + ); + }); + }); + + describe('selectAreAnyWalletsBlocked', () => { + it('returns true if any cached wallet is blocked', async () => { + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + [LOWERCASE_EVM_ADDRESS]: { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }, + }, + }, + }, + }, + ({ controller }) => { + expect( + selectAreAnyWalletsBlocked(['0xUNKNOWN', CHECKSUM_EVM_ADDRESS])( + controller.state, + ), + ).toBe(true); + }, + ); + }); + + it('returns false if no cached wallet is blocked', async () => { + await withController(({ controller }) => { + expect(selectAreAnyWalletsBlocked([])(controller.state)).toBe(false); + expect( + selectAreAnyWalletsBlocked([LOWERCASE_EVM_ADDRESS])(controller.state), + ).toBe(false); + }); + }); }); describe('ComplianceController:checkWalletCompliance', () => { @@ -169,6 +255,39 @@ describe('ComplianceController', () => { ); }); + it('returns an EVM cached result if the API call fails and only the address casing differs', async () => { + const cached = { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { [LOWERCASE_EVM_ADDRESS]: cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + CHECKSUM_EVM_ADDRESS, + ); + + expect(result).toStrictEqual(cached); + }, + ); + }); + it('re-throws the error if the API call fails and no cached entry exists', async () => { await withController(async ({ rootMessenger }) => { rootMessenger.registerActionHandler( @@ -311,6 +430,39 @@ describe('ComplianceController', () => { ); }); + it('returns cached EVM results for all addresses if only address casing differs', async () => { + const cached = { + address: LOWERCASE_EVM_ADDRESS, + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { + walletComplianceStatusMap: { [LOWERCASE_EVM_ADDRESS]: cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletsCompliance', + [CHECKSUM_EVM_ADDRESS], + ); + + expect(result).toStrictEqual([cached]); + }, + ); + }); + it('re-throws the error if the API call fails and any address has no cached entry', async () => { const cached = { address: '0xSAFE', diff --git a/packages/compliance-controller/src/ComplianceController.ts b/packages/compliance-controller/src/ComplianceController.ts index f8d7b4b808..4611b29f36 100644 --- a/packages/compliance-controller/src/ComplianceController.ts +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -12,6 +12,7 @@ import type { ComplianceServiceCheckWalletsComplianceAction, } from './ComplianceService-method-action-types'; import type { WalletComplianceStatus } from './types'; +import { getWalletComplianceStatus } from './utils'; // === GENERAL === @@ -208,7 +209,10 @@ export class ComplianceController extends BaseController< return status; } catch (error) { - const cached = this.state.walletComplianceStatusMap[address]; + const cached = getWalletComplianceStatus( + this.state.walletComplianceStatusMap, + address, + ); if (cached) { return cached; } @@ -252,10 +256,17 @@ export class ComplianceController extends BaseController< return statuses; } catch (error) { - const cachedStatuses = addresses.map( - (address) => this.state.walletComplianceStatusMap[address], + const cachedStatuses = addresses.map((address) => + getWalletComplianceStatus( + this.state.walletComplianceStatusMap, + address, + ), ); - if (cachedStatuses.every(Boolean)) { + if ( + cachedStatuses.every((status): status is WalletComplianceStatus => + Boolean(status), + ) + ) { return cachedStatuses; } throw error; diff --git a/packages/compliance-controller/src/ComplianceService.test.ts b/packages/compliance-controller/src/ComplianceService.test.ts index ad4c7eded7..269d24d958 100644 --- a/packages/compliance-controller/src/ComplianceService.test.ts +++ b/packages/compliance-controller/src/ComplianceService.test.ts @@ -11,6 +11,8 @@ import type { ComplianceServiceMessenger } from './ComplianceService'; import { ComplianceService } from './ComplianceService'; const MOCK_API_URL = 'https://compliance.dev-api.cx.metamask.io'; +const MOCK_PRODUCTION_API_URL = 'https://compliance.api.cx.metamask.io'; +const MOCK_CONFIGURED_API_URL = 'https://configured-compliance.example.com'; describe('ComplianceService', () => { beforeEach(() => { @@ -171,6 +173,89 @@ describe('ComplianceService', () => { error: expect.any(HttpError), }); }); + + it('uses the configured API URL when provided', async () => { + nock(MOCK_CONFIGURED_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ + options: { apiUrl: MOCK_CONFIGURED_API_URL, env: 'development' }, + }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('preserves path components in the configured API URL', async () => { + nock(MOCK_CONFIGURED_API_URL) + .get('/compliance/v1/wallet/0xABC123') + .reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}/compliance` }, + }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('defaults to the production API URL when no API URL or environment is provided', async () => { + nock(MOCK_PRODUCTION_API_URL).get('/v1/wallet/0xABC123').reply(200, { + address: '0xABC123', + blocked: false, + }); + const { rootMessenger } = getService({ useDefaultEnvironment: true }); + + const result = await rootMessenger.call( + 'ComplianceService:checkWalletCompliance', + '0xABC123', + ); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + }); + }); + + it('throws if the configured API URL is invalid', () => { + expect(() => + getService({ options: { apiUrl: 'not-a-valid-url' } }), + ).toThrow('Invalid Compliance API URL: not-a-valid-url'); + }); + + it('throws if the configured API URL includes a query string or fragment', () => { + expect(() => + getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}?foo=bar` }, + }), + ).toThrow( + `Invalid Compliance API URL: ${MOCK_CONFIGURED_API_URL}?foo=bar. Query strings and fragments are not supported.`, + ); + expect(() => + getService({ + options: { apiUrl: `${MOCK_CONFIGURED_API_URL}#anchor` }, + }), + ).toThrow( + `Invalid Compliance API URL: ${MOCK_CONFIGURED_API_URL}#anchor. Query strings and fragments are not supported.`, + ); + }); }); describe('ComplianceService:checkWalletsCompliance', () => { @@ -306,12 +391,16 @@ function getMessenger( * @param args.options - The options that the service constructor takes. All are * optional and will be filled in with defaults as needed (including * `messenger`). + * @param args.useDefaultEnvironment - Whether to omit the default test + * environment and use the service constructor default. * @returns The new service, root messenger, and service messenger. */ function getService({ options = {}, + useDefaultEnvironment = false, }: { options?: Partial[0]>; + useDefaultEnvironment?: boolean; } = {}): { service: ComplianceService; rootMessenger: RootMessenger; @@ -322,7 +411,7 @@ function getService({ const service = new ComplianceService({ fetch, messenger, - env: 'development', + ...(useDefaultEnvironment ? {} : { env: 'development' as const }), ...options, }); diff --git a/packages/compliance-controller/src/ComplianceService.ts b/packages/compliance-controller/src/ComplianceService.ts index 615ebb90c5..91fd92dece 100644 --- a/packages/compliance-controller/src/ComplianceService.ts +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -28,6 +28,22 @@ const COMPLIANCE_API_URLS: Record = { development: 'https://compliance.dev-api.cx.metamask.io', }; +export type ComplianceServiceOptions = { + messenger: ComplianceServiceMessenger; + fetch: typeof fetch; + /** + * Explicit Compliance API URL. Prefer this for application builds so API + * endpoints can be managed by build configuration. Path components are + * preserved as a base path for Compliance API routes. + */ + apiUrl?: string; + /** + * Fallback environment used when `apiUrl` is not provided. + */ + env?: ComplianceServiceEnvironment; + policyOptions?: CreateServicePolicyOptions; +}; + // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ @@ -126,7 +142,7 @@ type BatchWalletCheckResponseItem = Infer< * new ComplianceService({ * messenger: serviceMessenger, * fetch, - * env: 'production', + * apiUrl: 'https://compliance.api.cx.metamask.io', * }); * * // Check a single wallet @@ -173,26 +189,23 @@ export class ComplianceService { * @param args - The constructor arguments. * @param args.messenger - The messenger suited for this service. * @param args.fetch - A function that can be used to make an HTTP request. - * @param args.env - The environment to use for the Compliance API. Determines - * the base URL. + * @param args.apiUrl - The explicit Compliance API URL. + * @param args.env - The fallback environment to use for the Compliance API + * when `apiUrl` is not provided. * @param args.policyOptions - Options to pass to `createServicePolicy`, which * is used to wrap each request. See {@link CreateServicePolicyOptions}. */ constructor({ messenger, fetch: fetchFunction, - env, + apiUrl, + env = 'production', policyOptions = {}, - }: { - messenger: ComplianceServiceMessenger; - fetch: typeof fetch; - env: ComplianceServiceEnvironment; - policyOptions?: CreateServicePolicyOptions; - }) { + }: ComplianceServiceOptions) { this.name = serviceName; this.#messenger = messenger; this.#fetch = fetchFunction; - this.#complianceApiUrl = COMPLIANCE_API_URLS[env]; + this.#complianceApiUrl = getComplianceApiUrl({ apiUrl, env }); this.#policy = createServicePolicy(policyOptions); this.#messenger.registerMethodActionHandlers( @@ -248,7 +261,7 @@ export class ComplianceService { async checkWalletCompliance(address: string): Promise { const response = await this.#policy.execute(async () => { const url = new URL( - `/v1/wallet/${encodeURIComponent(address)}`, + `v1/wallet/${encodeURIComponent(address)}`, this.#complianceApiUrl, ); const localResponse = await this.#fetch(url); @@ -279,7 +292,7 @@ export class ComplianceService { addresses: string[], ): Promise { const response = await this.#policy.execute(async () => { - const url = new URL('/v1/wallet/batch', this.#complianceApiUrl); + const url = new URL('v1/wallet/batch', this.#complianceApiUrl); const localResponse = await this.#fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -303,6 +316,35 @@ export class ComplianceService { } } +function getComplianceApiUrl({ + apiUrl, + env, +}: { + apiUrl?: string; + env: ComplianceServiceEnvironment; +}): string { + if (apiUrl === undefined) { + return COMPLIANCE_API_URLS[env]; + } + + let url: URL; + try { + url = new URL(apiUrl); + } catch { + throw new Error(`Invalid Compliance API URL: ${apiUrl}`); + } + + if (url.search || url.hash) { + throw new Error( + `Invalid Compliance API URL: ${apiUrl}. Query strings and fragments are not supported.`, + ); + } + if (!url.pathname.endsWith('/')) { + url.pathname = `${url.pathname}/`; + } + return url.href; +} + /** * Validates an API response against a superstruct schema. * diff --git a/packages/compliance-controller/src/index.ts b/packages/compliance-controller/src/index.ts index 5b433e4729..fa6d2463e9 100644 --- a/packages/compliance-controller/src/index.ts +++ b/packages/compliance-controller/src/index.ts @@ -3,6 +3,7 @@ export type { ComplianceServiceEnvironment, ComplianceServiceEvents, ComplianceServiceMessenger, + ComplianceServiceOptions, } from './ComplianceService'; export type { ComplianceServiceCheckWalletComplianceAction, @@ -26,5 +27,5 @@ export { ComplianceController, getDefaultComplianceControllerState, } from './ComplianceController'; -export { selectIsWalletBlocked } from './selectors'; +export { selectAreAnyWalletsBlocked, selectIsWalletBlocked } from './selectors'; export type { WalletComplianceStatus } from './types'; diff --git a/packages/compliance-controller/src/selectors.ts b/packages/compliance-controller/src/selectors.ts index 4d8824c99c..1e8f6a4bd7 100644 --- a/packages/compliance-controller/src/selectors.ts +++ b/packages/compliance-controller/src/selectors.ts @@ -1,6 +1,7 @@ import { createSelector } from 'reselect'; import type { ComplianceControllerState } from './ComplianceController'; +import { getWalletComplianceStatus } from './utils'; const selectWalletComplianceStatusMap = ( state: ComplianceControllerState, @@ -20,5 +21,23 @@ export const selectIsWalletBlocked = ( ): ((state: ComplianceControllerState) => boolean) => createSelector( [selectWalletComplianceStatusMap], - (statusMap): boolean => statusMap[address]?.blocked ?? false, + (statusMap): boolean => + getWalletComplianceStatus(statusMap, address)?.blocked ?? false, + ); + +/** + * Creates a selector that returns whether any wallet address is blocked, based + * on the per-address compliance status cache. + * + * @param addresses - The wallet addresses to check. + * @returns A selector that takes `ComplianceControllerState` and returns + * `true` if any wallet is blocked, `false` otherwise. + */ +export const selectAreAnyWalletsBlocked = ( + addresses: string[], +): ((state: ComplianceControllerState) => boolean) => + createSelector([selectWalletComplianceStatusMap], (statusMap): boolean => + addresses.some( + (address) => getWalletComplianceStatus(statusMap, address)?.blocked, + ), ); diff --git a/packages/compliance-controller/src/utils.ts b/packages/compliance-controller/src/utils.ts new file mode 100644 index 0000000000..cdafbcc289 --- /dev/null +++ b/packages/compliance-controller/src/utils.ts @@ -0,0 +1,25 @@ +import { + isEqualCaseInsensitive, + isValidHexAddress, +} from '@metamask/controller-utils'; + +import type { WalletComplianceStatus } from './types'; + +export const getWalletComplianceStatus = ( + statusMap: Record, + address: string, +): WalletComplianceStatus | undefined => { + const exactMatch = statusMap[address]; + + if (exactMatch || !isValidHexAddress(address, { allowNonPrefixed: false })) { + return exactMatch; + } + + const matchingAddress = Object.keys(statusMap).find( + (cachedAddress) => + isValidHexAddress(cachedAddress, { allowNonPrefixed: false }) && + isEqualCaseInsensitive(cachedAddress, address), + ); + + return matchingAddress ? statusMap[matchingAddress] : undefined; +};