diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md index ea09fea6d62..1a2975b576c 100644 --- a/packages/compliance-controller/CHANGELOG.md +++ b/packages/compliance-controller/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Remove proactive bulk-fetch pattern from `ComplianceController` and `ComplianceService` ([#8365](https://github.com/MetaMask/core/pull/8365)) + - `ComplianceControllerState` no longer includes `blockedWallets` or `blockedWalletsLastFetched`. Consumers storing persisted state must drop these fields on migration. + - The `init()` and `updateBlockedWallets()` controller methods have been removed. Consumers should remove any calls to these methods. + - The `blockedWalletsRefreshInterval` constructor option has been removed. + - The `updateBlockedWallets()` service method and its `GET /v1/blocked-wallets` endpoint integration have been removed. + - `ComplianceControllerInitAction`, `ComplianceControllerUpdateBlockedWalletsAction`, and `ComplianceServiceUpdateBlockedWalletsAction` types have been removed from the public API. + - The `BlockedWalletsInfo` type has been removed from the public API. + - `checkWalletCompliance` and `checkWalletsCompliance` now fall back to the per-address `walletComplianceStatusMap` cache when the API is unavailable, re-throwing only if no cached result exists for a requested address. + - `selectIsWalletBlocked` now reads solely from `walletComplianceStatusMap` rather than also checking a cached full blocklist. - Bump `@metamask/controller-utils` from `^11.19.0` to `^11.20.0` ([#8344](https://github.com/MetaMask/core/pull/8344)) ## [1.0.2] diff --git a/packages/compliance-controller/src/ComplianceController-method-action-types.ts b/packages/compliance-controller/src/ComplianceController-method-action-types.ts index f64ca832414..602222842ff 100644 --- a/packages/compliance-controller/src/ComplianceController-method-action-types.ts +++ b/packages/compliance-controller/src/ComplianceController-method-action-types.ts @@ -5,19 +5,11 @@ import type { ComplianceController } from './ComplianceController'; -/** - * Initializes the controller by fetching the blocked wallets list if it - * is missing or stale. Call once after construction to ensure the blocklist - * is ready for `selectIsWalletBlocked` lookups. - */ -export type ComplianceControllerInitAction = { - type: `ComplianceController:init`; - handler: ComplianceController['init']; -}; - /** * Checks compliance status for a single wallet address via the API and - * persists the result to state. + * persists the result to state. If the API call fails and a previously + * cached result exists for the address, the cached result is returned as a + * fallback. If no cached result exists, the error is re-thrown. * * @param address - The wallet address to check. * @returns The compliance status of the wallet. @@ -29,7 +21,10 @@ export type ComplianceControllerCheckWalletComplianceAction = { /** * Checks compliance status for multiple wallet addresses via the API and - * persists the results to state. + * persists the results to state. If the API call fails and every requested + * address has a previously cached result, those cached results are returned + * as a fallback. If any address lacks a cached result, the error is + * re-thrown. * * @param addresses - The wallet addresses to check. * @returns The compliance statuses of the wallets. @@ -39,17 +34,6 @@ export type ComplianceControllerCheckWalletsComplianceAction = { handler: ComplianceController['checkWalletsCompliance']; }; -/** - * Fetches the full list of blocked wallets from the API and persists the - * data to state. This also updates the `blockedWalletsLastFetched` timestamp. - * - * @returns The blocked wallets information. - */ -export type ComplianceControllerUpdateBlockedWalletsAction = { - type: `ComplianceController:updateBlockedWallets`; - handler: ComplianceController['updateBlockedWallets']; -}; - /** * Clears all compliance data from state. */ @@ -62,8 +46,6 @@ export type ComplianceControllerClearComplianceStateAction = { * Union of all ComplianceController action types. */ export type ComplianceControllerMethodActions = - | ComplianceControllerInitAction | ComplianceControllerCheckWalletComplianceAction | ComplianceControllerCheckWalletsComplianceAction - | ComplianceControllerUpdateBlockedWalletsAction | ComplianceControllerClearComplianceStateAction; diff --git a/packages/compliance-controller/src/ComplianceController.test.ts b/packages/compliance-controller/src/ComplianceController.test.ts index 4d525697f00..687bcc376b9 100644 --- a/packages/compliance-controller/src/ComplianceController.test.ts +++ b/packages/compliance-controller/src/ComplianceController.test.ts @@ -10,12 +10,6 @@ import { ComplianceController } from './ComplianceController'; import type { ComplianceControllerMessenger } from './ComplianceController'; import { selectIsWalletBlocked } from './selectors'; -const MOCK_BLOCKED_WALLETS_RESPONSE = { - addresses: ['0xBLOCKED_A', '0xBLOCKED_B'], - sources: { ofac: 100, remote: 5 }, - lastUpdated: '2026-01-15T00:00:00.000Z', -}; - describe('ComplianceController', () => { describe('constructor', () => { it('accepts initial state', async () => { @@ -27,8 +21,6 @@ describe('ComplianceController', () => { checkedAt: '2026-01-01T00:00:00.000Z', }, }, - blockedWallets: null, - blockedWalletsLastFetched: 0, lastCheckedAt: '2026-01-01T00:00:00.000Z', }; @@ -44,8 +36,6 @@ describe('ComplianceController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { - "blockedWallets": null, - "blockedWalletsLastFetched": 0, "lastCheckedAt": null, "walletComplianceStatusMap": {}, } @@ -54,165 +44,15 @@ describe('ComplianceController', () => { }); }); - describe('init', () => { - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('fetches the blocked wallets list when it has never been fetched', async () => { - await withController(async ({ controller, rootMessenger }) => { - const updateBlockedWallets = jest.fn( - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - updateBlockedWallets, - ); - - await controller.init(); - - expect(updateBlockedWallets).toHaveBeenCalledTimes(1); - expect(controller.state.blockedWallets).toStrictEqual({ - ...MOCK_BLOCKED_WALLETS_RESPONSE, - fetchedAt: '2026-02-01T00:00:00.000Z', - }); - }); - }); - - it('fetches the blocked wallets list when the cache is stale', async () => { - const oneHourAgo = Date.now() - 60 * 60 * 1000 - 1; - await withController( - { - options: { - state: { blockedWalletsLastFetched: oneHourAgo }, - }, - }, - async ({ controller, rootMessenger }) => { - const updateBlockedWallets = jest.fn( - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - updateBlockedWallets, - ); - - await controller.init(); - - expect(updateBlockedWallets).toHaveBeenCalledTimes(1); - }, - ); - }); - - it('does not fetch when the cache is fresh', async () => { - await withController( - { - options: { - state: { blockedWalletsLastFetched: Date.now() }, - }, - }, - async ({ controller, rootMessenger }) => { - const updateBlockedWallets = jest.fn( - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - updateBlockedWallets, - ); - - await controller.init(); - - expect(updateBlockedWallets).not.toHaveBeenCalled(); - }, - ); - }); - - it('respects a custom blockedWalletsRefreshInterval', async () => { - const fiveMinutesAgo = Date.now() - 5 * 60 * 1000 - 1; - await withController( - { - options: { - state: { blockedWalletsLastFetched: fiveMinutesAgo }, - blockedWalletsRefreshInterval: 5 * 60 * 1000, - }, - }, - async ({ controller, rootMessenger }) => { - const updateBlockedWallets = jest.fn( - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - updateBlockedWallets, - ); - - await controller.init(); - - expect(updateBlockedWallets).toHaveBeenCalledTimes(1); - }, - ); - }); - }); - - describe('ComplianceController:init', () => { - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('does the same thing as the direct method', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); - - await rootMessenger.call('ComplianceController:init'); - - expect(controller.state.blockedWallets).toStrictEqual({ - ...MOCK_BLOCKED_WALLETS_RESPONSE, - fetchedAt: '2026-02-01T00:00:00.000Z', - }); - }); - }); - }); - describe('selectIsWalletBlocked', () => { - it('returns true if the wallet is in the cached blocklist', async () => { - await withController( - { - options: { - state: { - blockedWallets: { - addresses: ['0xBLOCKED_A', '0xBLOCKED_B'], - sources: { ofac: 2, remote: 0 }, - lastUpdated: '2026-01-01T00:00:00.000Z', - fetchedAt: '2026-01-01T00:00:00.000Z', - }, - }, - }, - }, - ({ controller }) => { - expect(selectIsWalletBlocked('0xBLOCKED_A')(controller.state)).toBe( - true, - ); - }, - ); - }); - - it('returns true if the wallet was checked on-demand and found blocked', async () => { + it('returns true if the wallet was checked and found blocked', async () => { await withController( { options: { state: { walletComplianceStatusMap: { - '0xON_DEMAND': { - address: '0xON_DEMAND', + '0xBLOCKED': { + address: '0xBLOCKED', blocked: true, checkedAt: '2026-01-01T00:00:00.000Z', }, @@ -221,14 +61,14 @@ describe('ComplianceController', () => { }, }, ({ controller }) => { - expect(selectIsWalletBlocked('0xON_DEMAND')(controller.state)).toBe( + expect(selectIsWalletBlocked('0xBLOCKED')(controller.state)).toBe( true, ); }, ); }); - it('returns false if the wallet is not in the blocklist or status map', async () => { + it('returns false if the wallet is not in the status map', async () => { await withController(({ controller }) => { expect(selectIsWalletBlocked('0xUNKNOWN')(controller.state)).toBe( false, @@ -256,34 +96,6 @@ describe('ComplianceController', () => { }, ); }); - - it('returns false if the blocklist is null and the address is unknown', async () => { - await withController(({ controller }) => { - expect(selectIsWalletBlocked('0xANYTHING')(controller.state)).toBe( - false, - ); - }); - }); - - it('performs case-sensitive lookup', async () => { - await withController( - { - options: { - state: { - blockedWallets: { - addresses: ['0xABC'], - sources: { ofac: 1, remote: 0 }, - lastUpdated: '2026-01-01T00:00:00.000Z', - fetchedAt: '2026-01-01T00:00:00.000Z', - }, - }, - }, - }, - ({ controller }) => { - expect(selectIsWalletBlocked('0xAbC')(controller.state)).toBe(false); - }, - ); - }); }); describe('ComplianceController:checkWalletCompliance', () => { @@ -325,6 +137,85 @@ describe('ComplianceController', () => { expect(controller.state.lastCheckedAt).toBe('2026-02-01T00:00:00.000Z'); }); }); + + it('returns the cached result if the API call fails and a cached entry exists', async () => { + const cached = { + address: '0xABC123', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + + await withController( + { + options: { + state: { walletComplianceStatusMap: { '0xABC123': cached } }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0xABC123', + ); + + 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( + 'ComplianceService:checkWalletCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + await expect( + rootMessenger.call( + 'ComplianceController:checkWalletCompliance', + '0xNEW', + ), + ).rejects.toThrow('API unavailable'); + }); + }); + }); + + describe('checkWalletCompliance', () => { + beforeEach(() => { + jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('does the same thing as the messenger action', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletCompliance', + async (address) => ({ + address, + blocked: false, + }), + ); + + const result = await controller.checkWalletCompliance('0xABC123'); + + expect(result).toStrictEqual({ + address: '0xABC123', + blocked: false, + checkedAt: '2026-02-01T00:00:00.000Z', + }); + }); + }); }); describe('ComplianceController:checkWalletsCompliance', () => { @@ -378,39 +269,79 @@ describe('ComplianceController', () => { }); }); }); - }); - describe('ComplianceController:updateBlockedWallets', () => { - beforeEach(() => { - jest.useFakeTimers().setSystemTime(new Date('2026-02-01')); - }); + it('returns cached results for all addresses if the API call fails and all are cached', async () => { + const cachedSafe = { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-01-01T00:00:00.000Z', + }; + const cachedBlocked = { + address: '0xBLOCKED', + blocked: true, + checkedAt: '2026-01-01T00:00:00.000Z', + }; - afterEach(() => { - jest.useRealTimers(); + await withController( + { + options: { + state: { + walletComplianceStatusMap: { + '0xSAFE': cachedSafe, + '0xBLOCKED': cachedBlocked, + }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); + + const result = await rootMessenger.call( + 'ComplianceController:checkWalletsCompliance', + ['0xSAFE', '0xBLOCKED'], + ); + + expect(result).toStrictEqual([cachedSafe, cachedBlocked]); + }, + ); }); - it('calls the service, persists data to state, and updates the lastFetched timestamp', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.registerActionHandler( - 'ComplianceService:updateBlockedWallets', - async () => MOCK_BLOCKED_WALLETS_RESPONSE, - ); + it('re-throws the error if the API call fails and any address has no cached entry', async () => { + const cached = { + address: '0xSAFE', + blocked: false, + checkedAt: '2026-01-01T00:00:00.000Z', + }; - const result = await rootMessenger.call( - 'ComplianceController:updateBlockedWallets', - ); + await withController( + { + options: { + state: { + walletComplianceStatusMap: { '0xSAFE': cached }, + }, + }, + }, + async ({ rootMessenger }) => { + rootMessenger.registerActionHandler( + 'ComplianceService:checkWalletsCompliance', + async () => { + throw new Error('API unavailable'); + }, + ); - expect(result).toStrictEqual({ - ...MOCK_BLOCKED_WALLETS_RESPONSE, - fetchedAt: '2026-02-01T00:00:00.000Z', - }); - expect(controller.state.blockedWallets).toStrictEqual({ - ...MOCK_BLOCKED_WALLETS_RESPONSE, - fetchedAt: '2026-02-01T00:00:00.000Z', - }); - expect(controller.state.blockedWalletsLastFetched).toBeGreaterThan(0); - expect(controller.state.lastCheckedAt).toBe('2026-02-01T00:00:00.000Z'); - }); + await expect( + rootMessenger.call('ComplianceController:checkWalletsCompliance', [ + '0xSAFE', + '0xNEW', + ]), + ).rejects.toThrow('API unavailable'); + }, + ); }); }); @@ -424,13 +355,6 @@ describe('ComplianceController', () => { checkedAt: '2026-01-01T00:00:00.000Z', }, }, - blockedWallets: { - addresses: ['0xABC'], - sources: { ofac: 10, remote: 1 }, - lastUpdated: '2026-01-01T00:00:00.000Z', - fetchedAt: '2026-01-01T00:00:00.000Z', - }, - blockedWalletsLastFetched: 1000, lastCheckedAt: '2026-01-01T00:00:00.000Z', }; @@ -441,8 +365,6 @@ describe('ComplianceController', () => { expect(controller.state).toStrictEqual({ walletComplianceStatusMap: {}, - blockedWallets: null, - blockedWalletsLastFetched: 0, lastCheckedAt: null, }); }, @@ -460,7 +382,6 @@ describe('ComplianceController', () => { checkedAt: '2026-01-01T00:00:00.000Z', }, }, - blockedWalletsLastFetched: 1000, lastCheckedAt: '2026-01-01T00:00:00.000Z', }; @@ -471,8 +392,6 @@ describe('ComplianceController', () => { expect(controller.state).toStrictEqual({ walletComplianceStatusMap: {}, - blockedWallets: null, - blockedWalletsLastFetched: 0, lastCheckedAt: null, }); }, @@ -503,7 +422,6 @@ describe('ComplianceController', () => { ), ).toMatchInlineSnapshot(` { - "blockedWalletsLastFetched": 0, "lastCheckedAt": null, } `); @@ -520,8 +438,6 @@ describe('ComplianceController', () => { ), ).toMatchInlineSnapshot(` { - "blockedWallets": null, - "blockedWalletsLastFetched": 0, "lastCheckedAt": null, "walletComplianceStatusMap": {}, } @@ -604,7 +520,6 @@ function getMessenger( actions: [ 'ComplianceService:checkWalletCompliance', 'ComplianceService:checkWalletsCompliance', - 'ComplianceService:updateBlockedWallets', ], events: [], messenger, diff --git a/packages/compliance-controller/src/ComplianceController.ts b/packages/compliance-controller/src/ComplianceController.ts index 65eecad7be3..f8d7b4b8086 100644 --- a/packages/compliance-controller/src/ComplianceController.ts +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -7,8 +7,11 @@ import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { ComplianceControllerMethodActions } from './ComplianceController-method-action-types'; -import type { ComplianceServiceMethodActions } from './ComplianceService-method-action-types'; -import type { BlockedWalletsInfo, WalletComplianceStatus } from './types'; +import type { + ComplianceServiceCheckWalletComplianceAction, + ComplianceServiceCheckWalletsComplianceAction, +} from './ComplianceService-method-action-types'; +import type { WalletComplianceStatus } from './types'; // === GENERAL === @@ -19,11 +22,6 @@ import type { BlockedWalletsInfo, WalletComplianceStatus } from './types'; */ export const controllerName = 'ComplianceController'; -/** - * The default refresh interval for the blocked wallets list (1 hour). - */ -const DEFAULT_BLOCKED_WALLETS_REFRESH_INTERVAL = 60 * 60 * 1000; - // === STATE === /** @@ -31,21 +29,11 @@ const DEFAULT_BLOCKED_WALLETS_REFRESH_INTERVAL = 60 * 60 * 1000; */ export type ComplianceControllerState = { /** - * A map of wallet addresses to their on-demand compliance check results. + * A map of wallet addresses to their compliance check results, used as a + * fallback cache when the API is unavailable. */ walletComplianceStatusMap: Record; - /** - * Information about all blocked wallets, or `null` if not yet fetched. - */ - blockedWallets: BlockedWalletsInfo | null; - - /** - * Timestamp (in milliseconds) of the last blocked wallets fetch, or 0 if - * never fetched. - */ - blockedWalletsLastFetched: number; - /** * The date/time (in ISO-8601 format) when the last compliance check was * performed, or `null` if no checks have been performed yet. @@ -63,18 +51,6 @@ const complianceControllerMetadata = { persist: true, usedInUi: true, }, - blockedWallets: { - includeInDebugSnapshot: false, - includeInStateLogs: false, - persist: true, - usedInUi: false, - }, - blockedWalletsLastFetched: { - includeInDebugSnapshot: false, - includeInStateLogs: true, - persist: true, - usedInUi: false, - }, lastCheckedAt: { includeInDebugSnapshot: false, includeInStateLogs: true, @@ -94,8 +70,6 @@ const complianceControllerMetadata = { export function getDefaultComplianceControllerState(): ComplianceControllerState { return { walletComplianceStatusMap: {}, - blockedWallets: null, - blockedWalletsLastFetched: 0, lastCheckedAt: null, }; } @@ -103,10 +77,8 @@ export function getDefaultComplianceControllerState(): ComplianceControllerState // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ - 'init', 'checkWalletCompliance', 'checkWalletsCompliance', - 'updateBlockedWallets', 'clearComplianceState', ] as const; @@ -128,7 +100,9 @@ export type ComplianceControllerActions = /** * Actions from other messengers that {@link ComplianceController} calls. */ -type AllowedActions = ComplianceServiceMethodActions; +type AllowedActions = + | ComplianceServiceCheckWalletComplianceAction + | ComplianceServiceCheckWalletsComplianceAction; /** * Published when the state of {@link ComplianceController} changes. @@ -162,21 +136,15 @@ export type ComplianceControllerMessenger = Messenger< /** * `ComplianceController` manages OFAC compliance state for wallet addresses. - * It proactively fetches and caches the blocked wallets list from the - * Compliance API so that consumers can perform synchronous lookups via the - * `selectIsWalletBlocked` selector without making API calls. + * It performs on-demand compliance checks via the API and caches results + * per address in state. Cached results serve as a fallback if the API is + * unavailable for a subsequent check on the same address. */ export class ComplianceController extends BaseController< typeof controllerName, ComplianceControllerState, ComplianceControllerMessenger > { - /** - * The interval (in milliseconds) after which the blocked wallets list - * is considered stale. - */ - readonly #blockedWalletsRefreshInterval: number; - /** * Constructs a new {@link ComplianceController}. * @@ -184,18 +152,13 @@ export class ComplianceController extends BaseController< * @param args.messenger - The messenger suited for this controller. * @param args.state - The desired state with which to init this * controller. Missing properties will be filled in with defaults. - * @param args.blockedWalletsRefreshInterval - The interval in milliseconds - * after which the blocked wallets list is considered stale. Defaults to 1 - * hour. */ constructor({ messenger, state, - blockedWalletsRefreshInterval = DEFAULT_BLOCKED_WALLETS_REFRESH_INTERVAL, }: { messenger: ComplianceControllerMessenger; state?: Partial; - blockedWalletsRefreshInterval?: number; }) { super({ messenger, @@ -207,28 +170,17 @@ export class ComplianceController extends BaseController< }, }); - this.#blockedWalletsRefreshInterval = blockedWalletsRefreshInterval; - this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, ); } - /** - * Initializes the controller by fetching the blocked wallets list if it - * is missing or stale. Call once after construction to ensure the blocklist - * is ready for `selectIsWalletBlocked` lookups. - */ - async init(): Promise { - if (this.#isBlockedWalletsStale()) { - await this.updateBlockedWallets(); - } - } - /** * Checks compliance status for a single wallet address via the API and - * persists the result to state. + * persists the result to state. If the API call fails and a previously + * cached result exists for the address, the cached result is returned as a + * fallback. If no cached result exists, the error is re-thrown. * * @param address - The wallet address to check. * @returns The compliance status of the wallet. @@ -236,29 +188,40 @@ export class ComplianceController extends BaseController< async checkWalletCompliance( address: string, ): Promise { - const result = await this.messenger.call( - 'ComplianceService:checkWalletCompliance', - address, - ); - - const now = new Date().toISOString(); - const status: WalletComplianceStatus = { - address: result.address, - blocked: result.blocked, - checkedAt: now, - }; - - this.update((draftState) => { - draftState.walletComplianceStatusMap[address] = status; - draftState.lastCheckedAt = now; - }); - - return status; + try { + const result = await this.messenger.call( + 'ComplianceService:checkWalletCompliance', + address, + ); + + const now = new Date().toISOString(); + const status: WalletComplianceStatus = { + address: result.address, + blocked: result.blocked, + checkedAt: now, + }; + + this.update((draftState) => { + draftState.walletComplianceStatusMap[address] = status; + draftState.lastCheckedAt = now; + }); + + return status; + } catch (error) { + const cached = this.state.walletComplianceStatusMap[address]; + if (cached) { + return cached; + } + throw error; + } } /** * Checks compliance status for multiple wallet addresses via the API and - * persists the results to state. + * persists the results to state. If the API call fails and every requested + * address has a previously cached result, those cached results are returned + * as a fallback. If any address lacks a cached result, the error is + * re-thrown. * * @param addresses - The wallet addresses to check. * @returns The compliance statuses of the wallets. @@ -266,55 +229,37 @@ export class ComplianceController extends BaseController< async checkWalletsCompliance( addresses: string[], ): Promise { - const results = await this.messenger.call( - 'ComplianceService:checkWalletsCompliance', - addresses, - ); - - const now = new Date().toISOString(); - const statuses: WalletComplianceStatus[] = results.map((result) => ({ - address: result.address, - blocked: result.blocked, - checkedAt: now, - })); - - this.update((draftState) => { - for (let idx = 0; idx < statuses.length; idx++) { - const callerAddress = addresses[idx]; - draftState.walletComplianceStatusMap[callerAddress] = statuses[idx]; + try { + const results = await this.messenger.call( + 'ComplianceService:checkWalletsCompliance', + addresses, + ); + + const now = new Date().toISOString(); + const statuses: WalletComplianceStatus[] = results.map((result) => ({ + address: result.address, + blocked: result.blocked, + checkedAt: now, + })); + + this.update((draftState) => { + for (let idx = 0; idx < statuses.length; idx++) { + const callerAddress = addresses[idx]; + draftState.walletComplianceStatusMap[callerAddress] = statuses[idx]; + } + draftState.lastCheckedAt = now; + }); + + return statuses; + } catch (error) { + const cachedStatuses = addresses.map( + (address) => this.state.walletComplianceStatusMap[address], + ); + if (cachedStatuses.every(Boolean)) { + return cachedStatuses; } - draftState.lastCheckedAt = now; - }); - - return statuses; - } - - /** - * Fetches the full list of blocked wallets from the API and persists the - * data to state. This also updates the `blockedWalletsLastFetched` timestamp. - * - * @returns The blocked wallets information. - */ - async updateBlockedWallets(): Promise { - const result = await this.messenger.call( - 'ComplianceService:updateBlockedWallets', - ); - - const now = new Date().toISOString(); - const blockedWallets: BlockedWalletsInfo = { - addresses: result.addresses, - sources: result.sources, - lastUpdated: result.lastUpdated, - fetchedAt: now, - }; - - this.update((draftState) => { - draftState.blockedWallets = blockedWallets; - draftState.blockedWalletsLastFetched = Date.now(); - draftState.lastCheckedAt = now; - }); - - return blockedWallets; + throw error; + } } /** @@ -323,23 +268,7 @@ export class ComplianceController extends BaseController< clearComplianceState(): void { this.update((draftState) => { draftState.walletComplianceStatusMap = {}; - draftState.blockedWallets = null; - draftState.blockedWalletsLastFetched = 0; draftState.lastCheckedAt = null; }); } - - /** - * Determines whether the blocked wallets list is stale and needs to be - * refreshed. - * - * @returns `true` if the list has never been fetched or the refresh - * interval has elapsed. - */ - #isBlockedWalletsStale(): boolean { - return ( - Date.now() - this.state.blockedWalletsLastFetched >= - this.#blockedWalletsRefreshInterval - ); - } } diff --git a/packages/compliance-controller/src/ComplianceService-method-action-types.ts b/packages/compliance-controller/src/ComplianceService-method-action-types.ts index d36819a360e..602eee5b8fd 100644 --- a/packages/compliance-controller/src/ComplianceService-method-action-types.ts +++ b/packages/compliance-controller/src/ComplianceService-method-action-types.ts @@ -27,20 +27,9 @@ export type ComplianceServiceCheckWalletsComplianceAction = { handler: ComplianceService['checkWalletsCompliance']; }; -/** - * Fetches the full list of blocked wallets and source metadata. - * - * @returns The blocked wallets data. - */ -export type ComplianceServiceUpdateBlockedWalletsAction = { - type: `ComplianceService:updateBlockedWallets`; - handler: ComplianceService['updateBlockedWallets']; -}; - /** * Union of all ComplianceService action types. */ export type ComplianceServiceMethodActions = | ComplianceServiceCheckWalletComplianceAction - | ComplianceServiceCheckWalletsComplianceAction - | ComplianceServiceUpdateBlockedWalletsAction; + | ComplianceServiceCheckWalletsComplianceAction; diff --git a/packages/compliance-controller/src/ComplianceService.test.ts b/packages/compliance-controller/src/ComplianceService.test.ts index 37f744d7bf5..ad4c7eded75 100644 --- a/packages/compliance-controller/src/ComplianceService.test.ts +++ b/packages/compliance-controller/src/ComplianceService.test.ts @@ -231,90 +231,6 @@ describe('ComplianceService', () => { }); }); - describe('ComplianceService:updateBlockedWallets', () => { - it('returns the blocked wallets data', async () => { - nock(MOCK_API_URL) - .get('/v1/blocked-wallets') - .reply(200, { - addresses: ['0xABC', '0xDEF'], - sources: { ofac: 100, remote: 5 }, - lastUpdated: '2026-01-01T00:00:00.000Z', - }); - const { rootMessenger } = getService(); - - const result = await rootMessenger.call( - 'ComplianceService:updateBlockedWallets', - ); - - expect(result).toStrictEqual({ - addresses: ['0xABC', '0xDEF'], - sources: { ofac: 100, remote: 5 }, - lastUpdated: '2026-01-01T00:00:00.000Z', - }); - }); - - it.each([ - 'not an object', - { - addresses: 'not an array', - sources: { ofac: 1, remote: 1 }, - lastUpdated: '2026-01-01', - }, - { - addresses: ['0xABC'], - sources: 'not an object', - lastUpdated: '2026-01-01', - }, - { - addresses: ['0xABC'], - sources: { ofac: 'nan', remote: 1 }, - lastUpdated: '2026-01-01', - }, - { - addresses: ['0xABC'], - sources: { ofac: 1, remote: 'nan' }, - lastUpdated: '2026-01-01', - }, - { - addresses: ['0xABC'], - sources: { ofac: 1, remote: 1 }, - lastUpdated: 123, - }, - { addresses: ['0xABC'], sources: { ofac: 1, remote: 1 } }, - { - addresses: [123], - sources: { ofac: 1, remote: 1 }, - lastUpdated: '2026-01-01', - }, - ])( - 'throws if the API returns a malformed response %o', - async (response) => { - nock(MOCK_API_URL) - .get('/v1/blocked-wallets') - .reply(200, JSON.stringify(response)); - const { rootMessenger } = getService(); - - await expect( - rootMessenger.call('ComplianceService:updateBlockedWallets'), - ).rejects.toThrow( - 'Malformed response received from compliance blocked wallets API', - ); - }, - ); - - it('throws an HttpError when the API returns a non-200 status', async () => { - nock(MOCK_API_URL).get('/v1/blocked-wallets').times(4).reply(503); - const { service } = getService(); - service.onRetry(() => { - jest.advanceTimersToNextTimerAsync().catch(console.error); - }); - - await expect(service.updateBlockedWallets()).rejects.toThrow( - /failed with status '503'/u, - ); - }); - }); - describe('checkWalletCompliance', () => { it('does the same thing as the messenger action', async () => { nock(MOCK_API_URL).get('/v1/wallet/0xABC123').reply(200, { @@ -345,27 +261,6 @@ describe('ComplianceService', () => { expect(result).toStrictEqual([{ address: '0xABC', blocked: true }]); }); }); - - describe('updateBlockedWallets', () => { - it('does the same thing as the messenger action', async () => { - nock(MOCK_API_URL) - .get('/v1/blocked-wallets') - .reply(200, { - addresses: ['0xABC'], - sources: { ofac: 50, remote: 2 }, - lastUpdated: '2026-02-01T00:00:00.000Z', - }); - const { service } = getService(); - - const result = await service.updateBlockedWallets(); - - expect(result).toStrictEqual({ - addresses: ['0xABC'], - sources: { ofac: 50, remote: 2 }, - lastUpdated: '2026-02-01T00:00:00.000Z', - }); - }); - }); }); /** diff --git a/packages/compliance-controller/src/ComplianceService.ts b/packages/compliance-controller/src/ComplianceService.ts index bd95787e6ce..615ebb90c50 100644 --- a/packages/compliance-controller/src/ComplianceService.ts +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -5,7 +5,7 @@ import type { import { createServicePolicy, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { Infer } from '@metamask/superstruct'; -import { array, boolean, number, object, string } from '@metamask/superstruct'; +import { array, boolean, object, string } from '@metamask/superstruct'; import type { IDisposable } from 'cockatiel'; import type { ComplianceServiceMethodActions } from './ComplianceService-method-action-types'; @@ -33,7 +33,6 @@ const COMPLIANCE_API_URLS: Record = { const MESSENGER_EXPOSED_METHODS = [ 'checkWalletCompliance', 'checkWalletsCompliance', - 'updateBlockedWallets', ] as const; /** @@ -94,23 +93,6 @@ type BatchWalletCheckResponseItem = Infer< typeof BatchWalletCheckResponseItemStruct >; -/** - * Schema for the response from `GET /v1/blocked-wallets`. - */ -const BlockedWalletsResponseStruct = object({ - addresses: array(string()), - sources: object({ - ofac: number(), - remote: number(), - }), - lastUpdated: string(), -}); - -/** - * The validated shape of the blocked wallets response. - */ -type BlockedWalletsResponse = Infer; - // === SERVICE DEFINITION === /** @@ -319,32 +301,6 @@ export class ComplianceService { 'compliance batch check API', ); } - - /** - * Fetches the full list of blocked wallets and source metadata. - * - * @returns The blocked wallets data. - */ - async updateBlockedWallets(): Promise { - const response = await this.#policy.execute(async () => { - const url = new URL('/v1/blocked-wallets', this.#complianceApiUrl); - const localResponse = await this.#fetch(url); - if (!localResponse.ok) { - throw new HttpError( - localResponse.status, - `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, - ); - } - return localResponse; - }); - const jsonResponse: unknown = await response.json(); - - return validateResponse( - jsonResponse, - BlockedWalletsResponseStruct, - 'compliance blocked wallets API', - ); - } } /** diff --git a/packages/compliance-controller/src/index.ts b/packages/compliance-controller/src/index.ts index e5a324e8725..5b433e4729f 100644 --- a/packages/compliance-controller/src/index.ts +++ b/packages/compliance-controller/src/index.ts @@ -7,7 +7,6 @@ export type { export type { ComplianceServiceCheckWalletComplianceAction, ComplianceServiceCheckWalletsComplianceAction, - ComplianceServiceUpdateBlockedWalletsAction, } from './ComplianceService-method-action-types'; export { ComplianceService } from './ComplianceService'; export type { @@ -22,12 +21,10 @@ export type { ComplianceControllerCheckWalletComplianceAction, ComplianceControllerCheckWalletsComplianceAction, ComplianceControllerClearComplianceStateAction, - ComplianceControllerUpdateBlockedWalletsAction, - ComplianceControllerInitAction, } from './ComplianceController-method-action-types'; export { ComplianceController, getDefaultComplianceControllerState, } from './ComplianceController'; export { selectIsWalletBlocked } from './selectors'; -export type { WalletComplianceStatus, BlockedWalletsInfo } from './types'; +export type { WalletComplianceStatus } from './types'; diff --git a/packages/compliance-controller/src/selectors.ts b/packages/compliance-controller/src/selectors.ts index b003af241c3..4d8824c99c6 100644 --- a/packages/compliance-controller/src/selectors.ts +++ b/packages/compliance-controller/src/selectors.ts @@ -2,10 +2,6 @@ import { createSelector } from 'reselect'; import type { ComplianceControllerState } from './ComplianceController'; -const selectBlockedWallets = ( - state: ComplianceControllerState, -): ComplianceControllerState['blockedWallets'] => state.blockedWallets; - const selectWalletComplianceStatusMap = ( state: ComplianceControllerState, ): ComplianceControllerState['walletComplianceStatusMap'] => @@ -13,8 +9,7 @@ const selectWalletComplianceStatusMap = ( /** * Creates a selector that returns whether a wallet address is blocked, based - * on the cached blocklist. The lookup checks the proactively fetched blocklist - * first, then falls back to the per-address compliance status map. + * on the per-address compliance status cache. * * @param address - The wallet address to check. * @returns A selector that takes `ComplianceControllerState` and returns @@ -24,11 +19,6 @@ export const selectIsWalletBlocked = ( address: string, ): ((state: ComplianceControllerState) => boolean) => createSelector( - [selectBlockedWallets, selectWalletComplianceStatusMap], - (blockedWallets, statusMap): boolean => { - if (blockedWallets?.addresses.includes(address)) { - return true; - } - return statusMap[address]?.blocked ?? false; - }, + [selectWalletComplianceStatusMap], + (statusMap): boolean => statusMap[address]?.blocked ?? false, ); diff --git a/packages/compliance-controller/src/types.ts b/packages/compliance-controller/src/types.ts index ac934efaed5..31cc5bb0ff3 100644 --- a/packages/compliance-controller/src/types.ts +++ b/packages/compliance-controller/src/types.ts @@ -17,32 +17,3 @@ export type WalletComplianceStatus = { */ checkedAt: string; }; - -/** - * Information about the full set of blocked wallets returned by the API. - */ -export type BlockedWalletsInfo = { - /** - * The list of all blocked wallet addresses. - */ - addresses: string[]; - - /** - * The number of blocked addresses from each source. - */ - sources: { - ofac: number; - remote: number; - }; - - /** - * The date/time (in ISO-8601 format) when the blocklist was last updated - * on the server. - */ - lastUpdated: string; - - /** - * The date/time (in ISO-8601 format) when this data was fetched. - */ - fetchedAt: string; -};