fix(compliance-controller): reconcile compliance-status writes case-insensitively - #10060
Open
gomesalexandre wants to merge 1 commit into
Open
Conversation
…nsensitively ComplianceController wrote a wallet's compliance status to walletComplianceStatusMap keyed by the caller's raw address casing, but reads went through getWalletComplianceStatus (added in MetaMask#8820), which tries an exact match first and falls back to a case-insensitive scan. Writing a status for an already-cached wallet under a different casing therefore created a second, stale entry instead of updating the existing one - and depending on which casing a later read used, it could see either the fresh or the stale status for the same wallet. setWalletComplianceStatus now collects every key that case-insensitively matches the incoming address, writes the new status under the first (existing) one, and deletes the rest - healing duplicates already present in persisted state, not just preventing new ones. Both write sites in ComplianceController route through it.
gomesalexandre
marked this pull request as ready for review
September 1, 2026 20:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ComplianceControllerwrites a wallet's compliance status towalletComplianceStatusMapkeyed by the caller's raw address casing, but reads go throughgetWalletComplianceStatus(src/utils.ts, added by #8820) — which tries an exact match first, then falls back to a case-insensitive scan. That asymmetry means a second write for an already-cached wallet under a different casing creates a stale duplicate entry instead of updating the existing one, and a subsequent read can return either the fresh or the stale status for the same wallet depending on which casing was queried.Rated as a stale-cache correctness bug on a sanctions-screening surface, not a demonstrated live OFAC-compliance bypass — see "Scope and severity" below.
The introducing commit is the whole argument
#8820 (merged, shipped in
2.1.0) createdutils.tsand converted all four read call sites to the new case-insensitive helper. Its own PR body states the intent directly:The diff contains not one line from either
this.update(...)write block —git show 7645e56cb0/the PR diff confirms only the twocatch-block fallback reads were touched. Casing-independence was the explicit goal; the write half needed the same treatment for it to actually hold across repeated writes under different casings.Repro
The map is
persist: true, usedInUi: true, so the stale duplicate survives app restarts and is what the UI actually reads.lastCheckedAtupdates on every write regardless of casing, so a stale entry's presentation looks freshly-checked even while it reflects a pre-sanction verdict.Fix
setWalletComplianceStatus(new, inutils.ts) collects every key in the map that case-insensitively matches the incoming address, writes the new status under the first (existing) one — preserving that key's casing so already-persisted state migrates for free without a breaking key-shape change — and deletes the rest. This also heals duplicate entries that already exist from before this fix ships (a real possibility, since the bug has been live since2.1.0), not just prevents new ones from forming.Both write sites in
ComplianceController.ts(checkWalletCompliance,checkWalletsCompliance) now route through it. The batch path's loop mutates the same map object in place, so a second alias of the same address later in the same batch call correctly reconciles against an entry a prior iteration in that same call just wrote.Scope and severity
The two-key/stale-read mechanism is proven end-to-end inside the package by the tests in this PR. What is not directly observed: a real client passing two different casings for the same wallet address in production. Both known consumers ship the affected version (extension
^2.1.0, mobile2.1.0), and the extension's compliance selectors forward an arbitraryaddress: stringwith no normalization before calling into this controller, so nothing downstream closes the gap either — meaning the bug is reachable if a caller (or two callers, e.g. an EOA reference vs. a contract-derived checksum) ever supplies inconsistent casing for the same wallet, but that hasn't been directly confirmed happening today.Tests
ComplianceController.test.ts:17-18already defines fixtures under both casings, but every existing mixed-casing test pre-seeds state with one casing then reads with the other — none of them drive a write under a second casing after an initial write under a different casing, which is exactly the gap this bug lived in.Added:
checkWalletsComplianceAll new tests independently confirmed to fail with the exact predicted symptom against the pre-fix code, and pass after. Full package suite: 53/53 passing (was 49), 100% statement/branch/function/line coverage maintained.
tsc --buildandeslintclean on all changed files.Dupe-check
gh issue/pr list --repo MetaMask/core --state all --searchforgetWalletComplianceStatus,walletComplianceStatusMapreturns nothing beyond #8820 (the introducing PR) and merged PRs predating the helper. None of the ~20 currently open compliance-related PRs touch this package.receipts
Reviewed adversarially with Codex across two passes. First pass (real, run to completion) caught a genuine high-severity gap in the initial version: exact-match-first meant pre-existing stale duplicates from before this fix shipped weren't healed on write, only prevented going forward. Rewrote
setWalletComplianceStatusto collect and reconcile all matching keys rather than stopping at the first, added the three tests above proving it, and got a clean second pass confirming the fix and asking only for the changelog entry (added).Note
Medium Risk
Touches persisted sanctions-screening cache correctness; the change reduces stale/wrong blocked status risk rather than expanding attack surface, but wrong compliance data is safety-sensitive.
Overview
Fixes a stale-cache correctness bug where
checkWalletComplianceandcheckWalletsCompliancekeyed persisted cache writes by the caller’s address casing while reads already matched EVM addresses case-insensitively. A second check for the same wallet under a different casing could leave an outdated entry alongside a fresh one, so UI/selectors might report the wrong blocked state.Writes now go through new
setWalletComplianceStatus, which updates the first existing case-insensitive match (keeping that key’s casing), removes any other duplicate keys, and collapses duplicate aliases within a single batch. Tests cover cross-casing updates, healing pre-existing duplicate persisted state, and batch reconciliation.Reviewed by Cursor Bugbot for commit 93213b1. Bugbot is set up for automated code reviews on this repo. Configure here.