Skip to content
Open
9 changes: 9 additions & 0 deletions packages/compliance-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions packages/compliance-controller/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -70,9 +70,6 @@ await rootMessenger.call('ComplianceController:checkWalletsCompliance', [
'0x1234...',
'0x5678...',
]);

// Fetch the full blocked wallets list
await rootMessenger.call('ComplianceController:updateBlockedWallets');
```

## Contributing
Expand Down
154 changes: 153 additions & 1 deletion packages/compliance-controller/src/ComplianceController.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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',
Expand Down
19 changes: 15 additions & 4 deletions packages/compliance-controller/src/ComplianceController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
ComplianceServiceCheckWalletsComplianceAction,
} from './ComplianceService-method-action-types';
import type { WalletComplianceStatus } from './types';
import { getWalletComplianceStatus } from './utils';

// === GENERAL ===

Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading