Skip to content

feat: compliance controller#7945

Merged
aganglada merged 9 commits intomainfrom
feat/compliance-controller
Feb 18, 2026
Merged

feat: compliance controller#7945
aganglada merged 9 commits intomainfrom
feat/compliance-controller

Conversation

@aganglada
Copy link
Contributor

@aganglada aganglada commented Feb 16, 2026

Explanation

This PR adds a new @metamask/compliance-controller package to the monorepo. It provides OFAC compliance checking for wallet addresses by interfacing with the Compliance API (va-mmcx-compliance-api).

The package consists of two main components:

ComplianceService (data service)

Handles all HTTP communication with the Compliance API. It wraps three endpoints:

  • checkWalletCompliance(address)GET /v1/wallet/:address for single address checks
  • checkWalletsCompliance(addresses)POST /v1/wallet/batch for batch address checks
  • fetchBlockedWallets()GET /v1/blocked-wallets for the full OFAC blocklist with source metadata

All requests are wrapped with createServicePolicy for automatic retries, circuit breaking, and degraded state detection. Response validation uses @metamask/superstruct schemas.

ComplianceController (controller)

Manages compliance state and provides a proactive caching pattern (similar to PhishingController):

  • On initialize(), it fetches and caches the full blocked wallets list if it is missing or stale (configurable refresh interval, defaults to 1 hour)
  • isWalletBlocked(address) performs a synchronous lookup against the cached blocklist — no API call needed
  • On-demand methods (checkWalletCompliance, checkWalletsCompliance) remain available for fresh per-address checks when needed (e.g., before high-value transactions)
  • clearComplianceState() resets all cached data

Test coverage

  • 54 tests with 100% coverage across statements, branches, functions, and lines
  • Controller tests follow the withController pattern
  • Service tests use nock for HTTP stubbing with retry, circuit breaker, and degraded state scenarios

References

  • Compliance API: va-mmcx-compliance-api (NestJS service providing OFAC SDN list checks)
  • Follows patterns from PhishingController (proactive blocklist caching), ClaimsController (PascalCase file structure), and SampleGasPricesController/SampleGasPricesService (messenger + service policy patterns)

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Introduces a new network-facing compliance component that can affect address blocking decisions and relies on external API responses/caching semantics; however it is largely additive and well-tested without changing existing controller behavior.

Overview
Adds a new @metamask/compliance-controller package that supports OFAC compliance checks against the Compliance API, including a ComplianceService (HTTP client with retry/circuit-break/degraded detection and response validation) and a ComplianceController that caches a blocked-wallets list plus per-address check results and exposes messenger actions (init, checkWalletCompliance, checkWalletsCompliance, updateBlockedWallets, clearComplianceState) and selectIsWalletBlocked.

Wires the new package into repo metadata (README package list/dependency graph, CODEOWNERS, teams.json, yarn.lock) and includes full unit test coverage plus standard package scaffolding (tsconfigs, typedoc, changelog, license).

Written by Cursor Bugbot for commit e493973. This will update automatically on new commits. Configure here.

@aganglada aganglada self-assigned this Feb 16, 2026
@aganglada aganglada force-pushed the feat/compliance-controller branch from abafb3c to eb49115 Compare February 16, 2026 17:33
@aganglada aganglada marked this pull request as ready for review February 17, 2026 11:42
@aganglada aganglada requested a review from a team as a code owner February 17, 2026 11:42
@cryptodev-2s
Copy link
Contributor

@aganglada Could also run yarn update-readme-content to update the root monorepo README.

// Fall back to per-address compliance status map
const status = this.state.walletComplianceStatusMap[address];
return status?.blocked ?? false;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-sensitive address lookup may miss blocked wallets

High Severity

isWalletBlocked uses case-sensitive Array.includes() for the blocklist and case-sensitive object key lookup for the status map. Ethereum addresses are hex and case-insensitive, so a casing mismatch between the blocklist and the queried address (e.g., 0xABC vs 0xabc) would cause a sanctioned wallet to be incorrectly reported as not blocked. The PhishingController in this same monorepo already uses toLowerCase() for address comparisons to avoid this problem.

Fix in Cursor Fix in Web

Copy link
Contributor

@cryptodev-2s cryptodev-2s left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I took an initial look and left a few suggestions to help reduce the boilerplate.

for (let idx = 0; idx < statuses.length; idx++) {
const callerAddress = addresses[idx];
draftState.walletComplianceStatusMap[callerAddress] = statuses[idx];
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Batch compliance mapping assumes API preserves input ordering

High Severity

checkWalletsCompliance maps results to state using positional indexing — addresses[idx] is used as the key for statuses[idx]. This assumes the batch API returns results in exactly the same order as the input addresses array. If the API returns results in a different order (or a different count), the wrong blocked status gets persisted against the wrong address. For an OFAC compliance controller, this could mark a sanctioned wallet as safe or a clean wallet as blocked. A safer approach would be to key by statuses[idx].address (which comes from result.address in the API response) rather than by positional index from the caller's input array.

Fix in Cursor Fix in Web

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hello there! I had some comments relating to aligning with best practices.

*
* @returns The blocked wallets information.
*/
async fetchBlockedWallets(): Promise<BlockedWalletsInfo> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Perhaps this should be called updateBlockedWallets? Besides fetching it's also updating the state.

}: {
messenger: ComplianceServiceMessenger;
fetch: typeof fetch;
complianceApiUrl: string;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason why you need this class to take a URL? This allows this class to theoretically represent any endpoint that satisfies the API being called here, however, it means that the clients need to store what that URL is, so that knowledge is not kept here.

If we are using multiple URLs for different environments what are your thoughts on having this class take an environment or env option and then constructing the URL here?

* is missing or stale. Call once after construction to ensure the blocklist
* is ready for `isWalletBlocked` lookups.
*/
async initialize(): Promise<void> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is not an official standard, however, in other controllers such as AccountTreeController use init rather than initialize for the name of the "post-construction initialization" method. What are your thoughts on renaming this method?

Alternatively, what are your thoughts on isWalletBlocked making an API call the first time it's called?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with init.
Regarding isWalletBlocked making the api call, this will mean the first call with be blocking and we don't want that

* @returns `true` if the wallet is blocked according to cached data,
* `false` otherwise.
*/
isWalletBlocked(address: string): boolean {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you truly don't want to make an API call here, since this method only accesses state, what are your thoughts on extracting this to a selector? See here for documentation on how to do this: https://github.com/MetaMask/core/blob/main/docs/code-guidelines/controller-guidelines.md#expose-derived-state-using-selectors-instead-of-getters (See here for defining selectors with arguments.)

ComplianceServiceCheckWalletComplianceAction,
ComplianceServiceCheckWalletsComplianceAction,
ComplianceServiceFetchBlockedWalletsAction,
ComplianceServiceMethodActions,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically we don't export this type, because we don't need it outside the package. Thoughts on removing?

Suggested change
ComplianceServiceMethodActions,

ComplianceControllerFetchBlockedWalletsAction,
ComplianceControllerInitializeAction,
ComplianceControllerIsWalletBlockedAction,
ComplianceControllerMethodActions,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above.

Suggested change
ComplianceControllerMethodActions,

@aganglada aganglada force-pushed the feat/compliance-controller branch from b63359f to 06e890c Compare February 18, 2026 09:54
Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

if (blockedWallets?.addresses.includes(address)) {
return true;
}
return statusMap[address]?.blocked ?? false;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Case-sensitive address matching bypasses OFAC compliance check

High Severity

selectIsWalletBlocked uses case-sensitive comparison (addresses.includes(address)) and case-sensitive map key lookup (statusMap[address]). Ethereum addresses are case-insensitive — EIP-55 only adds checksum casing for display. A blocked address stored as 0xABC... won't match a lookup for 0xabc..., allowing a sanctioned wallet to bypass the compliance check. The PhishingController in this same monorepo normalizes addresses with toLowerCase() before comparison for exactly this reason. Both the selector and the state storage in checkWalletCompliance/checkWalletsCompliance need address normalization.

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Contributor

@cryptodev-2s cryptodev-2s left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just one more comment!

ComplianceController,
getDefaultComplianceControllerState,
} from './ComplianceController';
export { selectIsWalletBlocked } from './selectors';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about modifying selectors.ts to place selectIsWalletBlocked (and any future selectors) in a complianceControllerSelectors object and exporting that instead?

Suggested change
export { selectIsWalletBlocked } from './selectors';
export { complianceControllerSelectors } from './selectors';

My thought with the guidance here is that if a React component or hook is using multiple selectors from multiple packages, if each package's selectors are bundled together in one object, it reduces the chance that there are conflicts.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guidance prevents tree-shaking. Our team will have to think about this some more. I don't want this to block the PR, so we can come back to this later.

Copy link
Contributor

@mcmire mcmire left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

@aganglada aganglada added this pull request to the merge queue Feb 18, 2026
Merged via the queue into main with commit 4945669 Feb 18, 2026
309 checks passed
@aganglada aganglada deleted the feat/compliance-controller branch February 18, 2026 16:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

Comments