From d0de4d55c42c9028eeee793b075f896bdd373c76 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 11:45:52 -0500 Subject: [PATCH 01/14] feat(compliance-controller): add configured Compliance API support --- packages/compliance-controller/CHANGELOG.md | 9 ++ packages/compliance-controller/README.md | 7 +- .../src/ComplianceController.test.ts | 153 +++++++++++++++++- .../src/ComplianceController.ts | 19 ++- .../src/ComplianceService.test.ts | 52 +++++- .../src/ComplianceService.ts | 52 ++++-- packages/compliance-controller/src/index.ts | 3 +- .../compliance-controller/src/selectors.ts | 44 ++++- 8 files changed, 315 insertions(+), 24 deletions(-) diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md index 69fdef8341..79ac800201 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. +- Add `selectAreAnyWalletsBlocked`. + ### 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. + ## [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..6bda101218 100644 --- a/packages/compliance-controller/src/ComplianceController.test.ts +++ b/packages/compliance-controller/src/ComplianceController.test.ts @@ -8,7 +8,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 = '0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; describe('ComplianceController', () => { describe('constructor', () => { @@ -96,6 +99,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 +254,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 +429,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..7e2ed42b2e 100644 --- a/packages/compliance-controller/src/ComplianceController.ts +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -11,6 +11,7 @@ import type { ComplianceServiceCheckWalletComplianceAction, ComplianceServiceCheckWalletsComplianceAction, } from './ComplianceService-method-action-types'; +import { getWalletComplianceStatus } from './selectors'; import type { WalletComplianceStatus } from './types'; // === 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..ba3ff64c7c 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,50 @@ 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('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'); + }); }); describe('ComplianceService:checkWalletsCompliance', () => { @@ -306,12 +352,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 +372,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..a20251ae6f 100644 --- a/packages/compliance-controller/src/ComplianceService.ts +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -28,6 +28,21 @@ 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. + */ + apiUrl?: string; + /** + * Fallback environment used when `apiUrl` is not provided. + */ + env?: ComplianceServiceEnvironment; + policyOptions?: CreateServicePolicyOptions; +}; + // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ @@ -126,7 +141,7 @@ type BatchWalletCheckResponseItem = Infer< * new ComplianceService({ * messenger: serviceMessenger, * fetch, - * env: 'production', + * apiUrl: 'https://compliance.api.cx.metamask.io', * }); * * // Check a single wallet @@ -173,26 +188,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( @@ -303,6 +315,24 @@ export class ComplianceService { } } +function getComplianceApiUrl({ + apiUrl, + env, +}: { + apiUrl?: string; + env: ComplianceServiceEnvironment; +}): string { + if (apiUrl === undefined) { + return COMPLIANCE_API_URLS[env]; + } + + try { + return new URL(apiUrl).href; + } catch { + throw new Error(`Invalid Compliance API URL: ${apiUrl}`); + } +} + /** * 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..77f0291382 100644 --- a/packages/compliance-controller/src/selectors.ts +++ b/packages/compliance-controller/src/selectors.ts @@ -1,12 +1,36 @@ +import { + isEqualCaseInsensitive, + isValidHexAddress, +} from '@metamask/controller-utils'; import { createSelector } from 'reselect'; import type { ComplianceControllerState } from './ComplianceController'; +import type { WalletComplianceStatus } from './types'; const selectWalletComplianceStatusMap = ( state: ComplianceControllerState, ): ComplianceControllerState['walletComplianceStatusMap'] => state.walletComplianceStatusMap; +export const getWalletComplianceStatus = ( + statusMap: ComplianceControllerState['walletComplianceStatusMap'], + 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; +}; + /** * Creates a selector that returns whether a wallet address is blocked, based * on the per-address compliance status cache. @@ -20,5 +44,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, + ), ); From 7a0993763469de9c417cae148c8a0ee40733dea0 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 12:04:46 -0500 Subject: [PATCH 02/14] chore: update changelog --- packages/compliance-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compliance-controller/CHANGELOG.md b/packages/compliance-controller/CHANGELOG.md index 79ac800201..6f0440ea88 100644 --- a/packages/compliance-controller/CHANGELOG.md +++ b/packages/compliance-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `ComplianceService` support for an explicit Compliance API URL. -- Add `selectAreAnyWalletsBlocked`. +- 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 @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Match EVM address casing consistently when reading cached wallet compliance statuses. +- Match EVM address casing consistently when reading cached wallet compliance statuses ([#8820](https://github.com/MetaMask/core/pull/8820)). ## [2.0.1] From dc5b149d4af3dd07de0df26706529dcb3a7ea9d8 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 14:59:14 -0500 Subject: [PATCH 03/14] chore: add file --- .../src/utils/accountUtils.test.ts | 71 +++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 packages/perps-controller/src/utils/accountUtils.test.ts diff --git a/packages/perps-controller/src/utils/accountUtils.test.ts b/packages/perps-controller/src/utils/accountUtils.test.ts new file mode 100644 index 0000000000..b1480ad6e6 --- /dev/null +++ b/packages/perps-controller/src/utils/accountUtils.test.ts @@ -0,0 +1,71 @@ +import type { InternalAccount } from '@metamask/keyring-internal-api'; + +import type { PerpsControllerMessengerBase } from '../types/messenger'; +import { getSelectedEvmAccountFromMessenger } from './accountUtils'; + +const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; +const GROUP_ADDRESS = '0x2222222222222222222222222222222222222222'; + +function buildEvmAccount( + address: string, + id: string, +): InternalAccount { + return { + address, + id, + type: 'eip155:eoa', + options: {}, + methods: [], + metadata: { + name: id, + importTime: Date.now(), + keyring: { + type: 'HD Key Tree', + }, + }, + scopes: ['eip155:0'], + } as InternalAccount; +} + +describe('getSelectedEvmAccountFromMessenger', () => { + it('prefers the selected account over the first evm account in the selected group', () => { + const selectedAccount = buildEvmAccount(SELECTED_ADDRESS, 'selected'); + const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); + const messenger = { + call: jest.fn((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }), + } as Pick; + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: SELECTED_ADDRESS, + }); + }); + + it('falls back to the selected account group when selected account lookup is unavailable', () => { + const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); + const messenger = { + call: jest.fn((actionType: string) => { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + throw new Error('Selected account unavailable'); + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + }), + } as Pick; + + expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ + address: GROUP_ADDRESS, + }); + }); +}); From 47a41231a94cbf41a1530779f0754fa0e81ac80c Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 15:20:15 -0500 Subject: [PATCH 04/14] chore: derive checksum address --- .../compliance-controller/src/ComplianceController.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/compliance-controller/src/ComplianceController.test.ts b/packages/compliance-controller/src/ComplianceController.test.ts index 6bda101218..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, @@ -11,7 +12,7 @@ import type { ComplianceControllerMessenger } from './ComplianceController'; import { selectAreAnyWalletsBlocked, selectIsWalletBlocked } from './selectors'; const LOWERCASE_EVM_ADDRESS = '0x4e1ff7229bddaf0a73df183a88d9c3a04cc975e0'; -const CHECKSUM_EVM_ADDRESS = '0x4e1fF7229BDdAf0A73DF183a88d9c3a04cc975e0'; +const CHECKSUM_EVM_ADDRESS = toChecksumHexAddress(LOWERCASE_EVM_ADDRESS); describe('ComplianceController', () => { describe('constructor', () => { From 3a27c0cf360bdf3ca1f9ae0c745da643a963c436 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 16:22:55 -0500 Subject: [PATCH 05/14] chore: move fn to utility --- .../src/ComplianceController.ts | 2 +- .../src/ComplianceService.test.ts | 41 ++++++++++++++++++- .../src/ComplianceService.ts | 20 +++++++-- .../compliance-controller/src/selectors.ts | 25 +---------- packages/compliance-controller/src/utils.ts | 25 +++++++++++ packages/perps-controller/CHANGELOG.md | 4 ++ .../perps-controller/src/types/messenger.ts | 7 ++++ .../src/utils/accountUtils.ts | 29 +++++++++++++ 8 files changed, 123 insertions(+), 30 deletions(-) create mode 100644 packages/compliance-controller/src/utils.ts diff --git a/packages/compliance-controller/src/ComplianceController.ts b/packages/compliance-controller/src/ComplianceController.ts index 7e2ed42b2e..4611b29f36 100644 --- a/packages/compliance-controller/src/ComplianceController.ts +++ b/packages/compliance-controller/src/ComplianceController.ts @@ -11,8 +11,8 @@ import type { ComplianceServiceCheckWalletComplianceAction, ComplianceServiceCheckWalletsComplianceAction, } from './ComplianceService-method-action-types'; -import { getWalletComplianceStatus } from './selectors'; import type { WalletComplianceStatus } from './types'; +import { getWalletComplianceStatus } from './utils'; // === GENERAL === diff --git a/packages/compliance-controller/src/ComplianceService.test.ts b/packages/compliance-controller/src/ComplianceService.test.ts index ba3ff64c7c..269d24d958 100644 --- a/packages/compliance-controller/src/ComplianceService.test.ts +++ b/packages/compliance-controller/src/ComplianceService.test.ts @@ -194,6 +194,28 @@ describe('ComplianceService', () => { }); }); + 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', @@ -217,6 +239,23 @@ describe('ComplianceService', () => { 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', () => { @@ -372,7 +411,7 @@ function getService({ const service = new ComplianceService({ fetch, messenger, - ...(!useDefaultEnvironment && { env: 'development' as const }), + ...(useDefaultEnvironment ? {} : { env: 'development' as const }), ...options, }); diff --git a/packages/compliance-controller/src/ComplianceService.ts b/packages/compliance-controller/src/ComplianceService.ts index a20251ae6f..91fd92dece 100644 --- a/packages/compliance-controller/src/ComplianceService.ts +++ b/packages/compliance-controller/src/ComplianceService.ts @@ -33,7 +33,8 @@ export type ComplianceServiceOptions = { fetch: typeof fetch; /** * Explicit Compliance API URL. Prefer this for application builds so API - * endpoints can be managed by build configuration. + * endpoints can be managed by build configuration. Path components are + * preserved as a base path for Compliance API routes. */ apiUrl?: string; /** @@ -260,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); @@ -291,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' }, @@ -326,11 +327,22 @@ function getComplianceApiUrl({ return COMPLIANCE_API_URLS[env]; } + let url: URL; try { - return new URL(apiUrl).href; + 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; } /** diff --git a/packages/compliance-controller/src/selectors.ts b/packages/compliance-controller/src/selectors.ts index 77f0291382..1e8f6a4bd7 100644 --- a/packages/compliance-controller/src/selectors.ts +++ b/packages/compliance-controller/src/selectors.ts @@ -1,36 +1,13 @@ -import { - isEqualCaseInsensitive, - isValidHexAddress, -} from '@metamask/controller-utils'; import { createSelector } from 'reselect'; import type { ComplianceControllerState } from './ComplianceController'; -import type { WalletComplianceStatus } from './types'; +import { getWalletComplianceStatus } from './utils'; const selectWalletComplianceStatusMap = ( state: ComplianceControllerState, ): ComplianceControllerState['walletComplianceStatusMap'] => state.walletComplianceStatusMap; -export const getWalletComplianceStatus = ( - statusMap: ComplianceControllerState['walletComplianceStatusMap'], - 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; -}; - /** * Creates a selector that returns whether a wallet address is blocked, based * on the per-address compliance status cache. 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; +}; diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 38b5a6b800..580a8ca92b 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `getSelectedEvmAccountFromMessenger` for resolving the selected EVM account with an account-group fallback ([#8820](https://github.com/MetaMask/core/pull/8820)) + ## [6.1.0] ### Changed diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index d5becee679..f255fd3609 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -7,6 +7,7 @@ import type { KeyringControllerGetStateAction, KeyringControllerSignTypedMessageAction, } from '@metamask/keyring-controller'; +import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerGetStateAction, @@ -20,6 +21,11 @@ import type { } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; +type AccountsControllerGetSelectedAccountAction = { + type: 'AccountsController:getSelectedAccount'; + handler: () => InternalAccount; +}; + /** * Actions from other controllers that PerpsController is allowed to call. */ @@ -32,6 +38,7 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction + | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 747cdd7620..d31991ef62 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -7,6 +7,7 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState, PerpsInternalAccount } from '../types'; import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); @@ -37,6 +38,34 @@ export function getSelectedEvmAccount( return getEvmAccountFromAccountGroup(accounts); } +export function getSelectedEvmAccountFromMessenger( + messenger: Pick, +): { address: string } | undefined { + try { + const selectedAccount = messenger.call( + 'AccountsController:getSelectedAccount', + ); + const evmAccount = selectedAccount + ? findEvmAccount([selectedAccount]) + : null; + if (evmAccount) { + return { address: evmAccount.address }; + } + } catch { + // Fall back to the selected account group if the direct lookup is unavailable. + } + + try { + return getSelectedEvmAccount( + messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); + } catch { + return undefined; + } +} + export type ReturnOnEquityInput = { unrealizedPnl: string | number; returnOnEquity: string | number; From 41777bf70c84875f2bfd10c831610889cc91a93c Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 16:31:46 -0500 Subject: [PATCH 06/14] chore: linting --- packages/perps-controller/src/utils/accountUtils.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/perps-controller/src/utils/accountUtils.test.ts b/packages/perps-controller/src/utils/accountUtils.test.ts index b1480ad6e6..a77405806f 100644 --- a/packages/perps-controller/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/src/utils/accountUtils.test.ts @@ -6,10 +6,7 @@ import { getSelectedEvmAccountFromMessenger } from './accountUtils'; const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; const GROUP_ADDRESS = '0x2222222222222222222222222222222222222222'; -function buildEvmAccount( - address: string, - id: string, -): InternalAccount { +function buildEvmAccount(address: string, id: string): InternalAccount { return { address, id, From d430cc8cc0b7001273a66d77915613102616c2b2 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 17:24:20 -0500 Subject: [PATCH 07/14] fix: bugbot findings --- packages/perps-controller/CHANGELOG.md | 4 -- .../perps-controller/src/types/messenger.ts | 7 --- .../src/utils/accountUtils.test.ts | 59 +++++++++++-------- .../src/utils/accountUtils.ts | 12 +++- 4 files changed, 44 insertions(+), 38 deletions(-) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 580a8ca92b..38b5a6b800 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Add `getSelectedEvmAccountFromMessenger` for resolving the selected EVM account with an account-group fallback ([#8820](https://github.com/MetaMask/core/pull/8820)) - ## [6.1.0] ### Changed diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index f255fd3609..d5becee679 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -7,7 +7,6 @@ import type { KeyringControllerGetStateAction, KeyringControllerSignTypedMessageAction, } from '@metamask/keyring-controller'; -import type { InternalAccount } from '@metamask/keyring-internal-api'; import type { Messenger } from '@metamask/messenger'; import type { NetworkControllerGetStateAction, @@ -21,11 +20,6 @@ import type { } from '@metamask/remote-feature-flag-controller'; import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; -type AccountsControllerGetSelectedAccountAction = { - type: 'AccountsController:getSelectedAccount'; - handler: () => InternalAccount; -}; - /** * Actions from other controllers that PerpsController is allowed to call. */ @@ -38,7 +32,6 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction - | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; diff --git a/packages/perps-controller/src/utils/accountUtils.test.ts b/packages/perps-controller/src/utils/accountUtils.test.ts index a77405806f..11cab97b61 100644 --- a/packages/perps-controller/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/src/utils/accountUtils.test.ts @@ -1,6 +1,5 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; -import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccountFromMessenger } from './accountUtils'; const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; @@ -28,18 +27,23 @@ describe('getSelectedEvmAccountFromMessenger', () => { it('prefers the selected account over the first evm account in the selected group', () => { const selectedAccount = buildEvmAccount(SELECTED_ADDRESS, 'selected'); const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - const messenger = { - call: jest.fn((actionType: string) => { - switch (actionType) { - case 'AccountsController:getSelectedAccount': - return selectedAccount; - case 'AccountTreeController:getAccountsFromSelectedAccountGroup': - return [groupedAccount]; - default: - throw new Error(`Unexpected action: ${actionType}`); - } - }), - } as Pick; + function call( + actionType: 'AccountsController:getSelectedAccount', + ): InternalAccount; + function call( + actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): InternalAccount[]; + function call(actionType: string): InternalAccount | InternalAccount[] { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + return selectedAccount; + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + } + const messenger = { call }; expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ address: SELECTED_ADDRESS, @@ -48,18 +52,23 @@ describe('getSelectedEvmAccountFromMessenger', () => { it('falls back to the selected account group when selected account lookup is unavailable', () => { const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - const messenger = { - call: jest.fn((actionType: string) => { - switch (actionType) { - case 'AccountsController:getSelectedAccount': - throw new Error('Selected account unavailable'); - case 'AccountTreeController:getAccountsFromSelectedAccountGroup': - return [groupedAccount]; - default: - throw new Error(`Unexpected action: ${actionType}`); - } - }), - } as Pick; + function call( + actionType: 'AccountsController:getSelectedAccount', + ): InternalAccount; + function call( + actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): InternalAccount[]; + function call(actionType: string): InternalAccount | InternalAccount[] { + switch (actionType) { + case 'AccountsController:getSelectedAccount': + throw new Error('Selected account unavailable'); + case 'AccountTreeController:getAccountsFromSelectedAccountGroup': + return [groupedAccount]; + default: + throw new Error(`Unexpected action: ${actionType}`); + } + } + const messenger = { call }; expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ address: GROUP_ADDRESS, diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index d31991ef62..42e6619c10 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -7,10 +7,18 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState, PerpsInternalAccount } from '../types'; import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; -import type { PerpsControllerMessengerBase } from '../types/messenger'; const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); +type SelectedEvmAccountMessenger = { + call( + actionType: 'AccountsController:getSelectedAccount', + ): InternalAccount | undefined; + call( + actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ): (InternalAccount | PerpsInternalAccount)[]; +}; + function isEvmAccountType(type: string): boolean { return EVM_ACCOUNT_TYPES.has(type); } @@ -39,7 +47,7 @@ export function getSelectedEvmAccount( } export function getSelectedEvmAccountFromMessenger( - messenger: Pick, + messenger: SelectedEvmAccountMessenger, ): { address: string } | undefined { try { const selectedAccount = messenger.call( From 526d073c2bd8b1d97c0a68df26ea161635468e7f Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 17:27:16 -0500 Subject: [PATCH 08/14] fix: update changelog --- packages/perps-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 38b5a6b800..8c2591fe20 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Prefer the selected EVM account before falling back to the selected account group when resolving perps accounts ([#8820](https://github.com/MetaMask/core/pull/8820)) + ## [6.1.0] ### Changed From c7aa2d7cf4e0e738973b9bff03d9769d73e0e5e9 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 17:40:21 -0500 Subject: [PATCH 09/14] fix: update imports --- .../perps-controller/src/PerpsController.ts | 26 +++------------ .../src/utils/accountUtils.test.ts | 12 ------- .../src/utils/accountUtils.ts | 32 +++++++++++++------ 3 files changed, 27 insertions(+), 43 deletions(-) diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 008abd0401..2ed35aa1f2 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -120,7 +120,7 @@ import { LastTransactionResult, TransactionStatus, } from './types/transactionTypes'; -import { getSelectedEvmAccount } from './utils/accountUtils'; +import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; import { hydrateFromDiskSync, @@ -1155,11 +1155,7 @@ export class PerpsController extends BaseController< // Get current user address for validation let currentAddress: string | null = null; try { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); currentAddress = evmAccount?.address ?? null; } catch { // Can't determine current account — trust the cache @@ -2215,11 +2211,7 @@ export class PerpsController extends BaseController< currentDepositId = depositId; // Get current account address via messenger (outside of update() for proper typing) - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const accountAddress = evmAccount?.address ?? 'unknown'; this.update((state) => { @@ -3098,11 +3090,7 @@ export class PerpsController extends BaseController< // Watch for account changes via AccountTreeController const accountChangeHandler = (): void => { - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const currentAddress = evmAccount?.address ?? null; // If any cached entry belongs to a different account, clear all entries. @@ -3339,11 +3327,7 @@ export class PerpsController extends BaseController< } // Get current user address - const evmAccount = getSelectedEvmAccount( - this.messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); + const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); if (!evmAccount?.address) { return; } diff --git a/packages/perps-controller/src/utils/accountUtils.test.ts b/packages/perps-controller/src/utils/accountUtils.test.ts index 11cab97b61..d8bf726d51 100644 --- a/packages/perps-controller/src/utils/accountUtils.test.ts +++ b/packages/perps-controller/src/utils/accountUtils.test.ts @@ -27,12 +27,6 @@ describe('getSelectedEvmAccountFromMessenger', () => { it('prefers the selected account over the first evm account in the selected group', () => { const selectedAccount = buildEvmAccount(SELECTED_ADDRESS, 'selected'); const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - function call( - actionType: 'AccountsController:getSelectedAccount', - ): InternalAccount; - function call( - actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ): InternalAccount[]; function call(actionType: string): InternalAccount | InternalAccount[] { switch (actionType) { case 'AccountsController:getSelectedAccount': @@ -52,12 +46,6 @@ describe('getSelectedEvmAccountFromMessenger', () => { it('falls back to the selected account group when selected account lookup is unavailable', () => { const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - function call( - actionType: 'AccountsController:getSelectedAccount', - ): InternalAccount; - function call( - actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ): InternalAccount[]; function call(actionType: string): InternalAccount | InternalAccount[] { switch (actionType) { case 'AccountsController:getSelectedAccount': diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 42e6619c10..c3da007d5e 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -3,6 +3,7 @@ * Handles account selection and EVM account filtering */ import type { InternalAccount } from '@metamask/keyring-internal-api'; +import { hasProperty } from '@metamask/utils'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import type { AccountState, PerpsInternalAccount } from '../types'; @@ -11,18 +12,26 @@ import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types' const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); type SelectedEvmAccountMessenger = { - call( - actionType: 'AccountsController:getSelectedAccount', - ): InternalAccount | undefined; - call( - actionType: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ): (InternalAccount | PerpsInternalAccount)[]; + call(actionType: string): unknown; }; function isEvmAccountType(type: string): boolean { return EVM_ACCOUNT_TYPES.has(type); } +function isAccountWithAddressAndType( + account: unknown, +): account is InternalAccount | PerpsInternalAccount { + return ( + typeof account === 'object' && + account !== null && + hasProperty(account, 'address') && + typeof account.address === 'string' && + hasProperty(account, 'type') && + typeof account.type === 'string' + ); +} + export function findEvmAccount( accounts: (InternalAccount | PerpsInternalAccount)[], ): InternalAccount | PerpsInternalAccount | null { @@ -53,7 +62,7 @@ export function getSelectedEvmAccountFromMessenger( const selectedAccount = messenger.call( 'AccountsController:getSelectedAccount', ); - const evmAccount = selectedAccount + const evmAccount = isAccountWithAddressAndType(selectedAccount) ? findEvmAccount([selectedAccount]) : null; if (evmAccount) { @@ -64,10 +73,13 @@ export function getSelectedEvmAccountFromMessenger( } try { + const accounts = messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ); return getSelectedEvmAccount( - messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), + Array.isArray(accounts) + ? accounts.filter(isAccountWithAddressAndType) + : [], ); } catch { return undefined; From 967dd23de7f5031714d092feebb7a150818fbe0e Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 18:14:05 -0500 Subject: [PATCH 10/14] fix: type error --- packages/perps-controller/src/utils/accountUtils.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index c3da007d5e..28febcdf82 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -12,7 +12,7 @@ import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types' const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); type SelectedEvmAccountMessenger = { - call(actionType: string): unknown; + call: unknown; }; function isEvmAccountType(type: string): boolean { @@ -58,10 +58,13 @@ export function getSelectedEvmAccount( export function getSelectedEvmAccountFromMessenger( messenger: SelectedEvmAccountMessenger, ): { address: string } | undefined { + if (typeof messenger.call !== 'function') { + return undefined; + } + const call = messenger.call as (actionType: string) => unknown; + try { - const selectedAccount = messenger.call( - 'AccountsController:getSelectedAccount', - ); + const selectedAccount = call('AccountsController:getSelectedAccount'); const evmAccount = isAccountWithAddressAndType(selectedAccount) ? findEvmAccount([selectedAccount]) : null; @@ -73,7 +76,7 @@ export function getSelectedEvmAccountFromMessenger( } try { - const accounts = messenger.call( + const accounts = call( 'AccountTreeController:getAccountsFromSelectedAccountGroup', ); return getSelectedEvmAccount( From 1c7c51324faf522e4442698c4bbb5b2208734027 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 18:22:35 -0500 Subject: [PATCH 11/14] fix: add comment regarding weak type --- packages/perps-controller/src/utils/accountUtils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 28febcdf82..5f94520a20 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -11,6 +11,10 @@ import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types' const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); +// Keep this dependency-free: importing the official +// AccountsControllerGetSelectedAccountAction type would require adding +// @metamask/accounts-controller to perps-controller just for this helper. +// Runtime guards below validate the account shape before use. type SelectedEvmAccountMessenger = { call: unknown; }; From ec316eee541e3c33189912b780223218bfeedab9 Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 18:46:25 -0500 Subject: [PATCH 12/14] fix: use correct account action type, allowed action union --- packages/perps-controller/package.json | 1 + .../perps-controller/src/types/messenger.ts | 2 + .../src/utils/accountUtils.ts | 47 ++++--------------- yarn.lock | 1 + 4 files changed, 13 insertions(+), 38 deletions(-) diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 099b468bf4..2dfc5256a6 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -68,6 +68,7 @@ }, "devDependencies": { "@metamask/account-tree-controller": "^7.4.0", + "@metamask/accounts-controller": "^38.1.1", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^0.1.3", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index d5becee679..c8e0d6bfec 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -2,6 +2,7 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -32,6 +33,7 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction + | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 5f94520a20..737526df90 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -3,39 +3,18 @@ * Handles account selection and EVM account filtering */ import type { InternalAccount } from '@metamask/keyring-internal-api'; -import { hasProperty } from '@metamask/utils'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; +import type { PerpsControllerMessenger } from '../PerpsController'; import type { AccountState, PerpsInternalAccount } from '../types'; import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); -// Keep this dependency-free: importing the official -// AccountsControllerGetSelectedAccountAction type would require adding -// @metamask/accounts-controller to perps-controller just for this helper. -// Runtime guards below validate the account shape before use. -type SelectedEvmAccountMessenger = { - call: unknown; -}; - function isEvmAccountType(type: string): boolean { return EVM_ACCOUNT_TYPES.has(type); } -function isAccountWithAddressAndType( - account: unknown, -): account is InternalAccount | PerpsInternalAccount { - return ( - typeof account === 'object' && - account !== null && - hasProperty(account, 'address') && - typeof account.address === 'string' && - hasProperty(account, 'type') && - typeof account.type === 'string' - ); -} - export function findEvmAccount( accounts: (InternalAccount | PerpsInternalAccount)[], ): InternalAccount | PerpsInternalAccount | null { @@ -60,18 +39,13 @@ export function getSelectedEvmAccount( } export function getSelectedEvmAccountFromMessenger( - messenger: SelectedEvmAccountMessenger, + messenger: Pick, ): { address: string } | undefined { - if (typeof messenger.call !== 'function') { - return undefined; - } - const call = messenger.call as (actionType: string) => unknown; - try { - const selectedAccount = call('AccountsController:getSelectedAccount'); - const evmAccount = isAccountWithAddressAndType(selectedAccount) - ? findEvmAccount([selectedAccount]) - : null; + const selectedAccount = messenger.call( + 'AccountsController:getSelectedAccount', + ); + const evmAccount = findEvmAccount([selectedAccount]); if (evmAccount) { return { address: evmAccount.address }; } @@ -80,13 +54,10 @@ export function getSelectedEvmAccountFromMessenger( } try { - const accounts = call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); return getSelectedEvmAccount( - Array.isArray(accounts) - ? accounts.filter(isAccountWithAddressAndType) - : [], + messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), ); } catch { return undefined; diff --git a/yarn.lock b/yarn.lock index d177fa888e..2d0e174823 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,6 +4979,7 @@ __metadata: dependencies: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^7.4.0" + "@metamask/accounts-controller": "npm:^38.1.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0" From e2a008b92001f739bbdfc801781f1e06780b509c Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 19:12:30 -0500 Subject: [PATCH 13/14] chore: update readme for dep --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 64a2300566..3047b668f5 100644 --- a/README.md +++ b/README.md @@ -450,6 +450,7 @@ linkStyle default opacity:0.5 perps_controller --> controller_utils; perps_controller --> messenger; perps_controller --> account_tree_controller; + perps_controller --> accounts_controller; perps_controller --> geolocation_controller; perps_controller --> keyring_controller; perps_controller --> network_controller; From ce4d3bf5a26c4e8bb4d87805cd2f5d7639f4a2fa Mon Sep 17 00:00:00 2001 From: geositta Date: Thu, 14 May 2026 21:43:44 -0500 Subject: [PATCH 14/14] chore: remove perps changes from compliance pr --- README.md | 1 - packages/perps-controller/CHANGELOG.md | 4 -- packages/perps-controller/package.json | 1 - .../perps-controller/src/PerpsController.ts | 26 ++++++-- .../perps-controller/src/types/messenger.ts | 2 - .../src/utils/accountUtils.test.ts | 65 ------------------- .../src/utils/accountUtils.ts | 27 -------- yarn.lock | 1 - 8 files changed, 21 insertions(+), 106 deletions(-) delete mode 100644 packages/perps-controller/src/utils/accountUtils.test.ts diff --git a/README.md b/README.md index 3047b668f5..64a2300566 100644 --- a/README.md +++ b/README.md @@ -450,7 +450,6 @@ linkStyle default opacity:0.5 perps_controller --> controller_utils; perps_controller --> messenger; perps_controller --> account_tree_controller; - perps_controller --> accounts_controller; perps_controller --> geolocation_controller; perps_controller --> keyring_controller; perps_controller --> network_controller; diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index 8c2591fe20..38b5a6b800 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- Prefer the selected EVM account before falling back to the selected account group when resolving perps accounts ([#8820](https://github.com/MetaMask/core/pull/8820)) - ## [6.1.0] ### Changed diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index 2dfc5256a6..099b468bf4 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -68,7 +68,6 @@ }, "devDependencies": { "@metamask/account-tree-controller": "^7.4.0", - "@metamask/accounts-controller": "^38.1.1", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^0.1.3", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 2ed35aa1f2..008abd0401 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -120,7 +120,7 @@ import { LastTransactionResult, TransactionStatus, } from './types/transactionTypes'; -import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils'; +import { getSelectedEvmAccount } from './utils/accountUtils'; import { ensureError } from './utils/errorUtils'; import { hydrateFromDiskSync, @@ -1155,7 +1155,11 @@ export class PerpsController extends BaseController< // Get current user address for validation let currentAddress: string | null = null; try { - const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); currentAddress = evmAccount?.address ?? null; } catch { // Can't determine current account — trust the cache @@ -2211,7 +2215,11 @@ export class PerpsController extends BaseController< currentDepositId = depositId; // Get current account address via messenger (outside of update() for proper typing) - const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); const accountAddress = evmAccount?.address ?? 'unknown'; this.update((state) => { @@ -3090,7 +3098,11 @@ export class PerpsController extends BaseController< // Watch for account changes via AccountTreeController const accountChangeHandler = (): void => { - const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); const currentAddress = evmAccount?.address ?? null; // If any cached entry belongs to a different account, clear all entries. @@ -3327,7 +3339,11 @@ export class PerpsController extends BaseController< } // Get current user address - const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount?.address) { return; } diff --git a/packages/perps-controller/src/types/messenger.ts b/packages/perps-controller/src/types/messenger.ts index c8e0d6bfec..d5becee679 100644 --- a/packages/perps-controller/src/types/messenger.ts +++ b/packages/perps-controller/src/types/messenger.ts @@ -2,7 +2,6 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, AccountTreeControllerSelectedAccountGroupChangeEvent, } from '@metamask/account-tree-controller'; -import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { KeyringControllerGetStateAction, @@ -33,7 +32,6 @@ export type PerpsControllerAllowedActions = | KeyringControllerSignTypedMessageAction | TransactionControllerAddTransactionAction | RemoteFeatureFlagControllerGetStateAction - | AccountsControllerGetSelectedAccountAction | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction | AuthenticationController.AuthenticationControllerGetBearerTokenAction; diff --git a/packages/perps-controller/src/utils/accountUtils.test.ts b/packages/perps-controller/src/utils/accountUtils.test.ts deleted file mode 100644 index d8bf726d51..0000000000 --- a/packages/perps-controller/src/utils/accountUtils.test.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { InternalAccount } from '@metamask/keyring-internal-api'; - -import { getSelectedEvmAccountFromMessenger } from './accountUtils'; - -const SELECTED_ADDRESS = '0x1111111111111111111111111111111111111111'; -const GROUP_ADDRESS = '0x2222222222222222222222222222222222222222'; - -function buildEvmAccount(address: string, id: string): InternalAccount { - return { - address, - id, - type: 'eip155:eoa', - options: {}, - methods: [], - metadata: { - name: id, - importTime: Date.now(), - keyring: { - type: 'HD Key Tree', - }, - }, - scopes: ['eip155:0'], - } as InternalAccount; -} - -describe('getSelectedEvmAccountFromMessenger', () => { - it('prefers the selected account over the first evm account in the selected group', () => { - const selectedAccount = buildEvmAccount(SELECTED_ADDRESS, 'selected'); - const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - function call(actionType: string): InternalAccount | InternalAccount[] { - switch (actionType) { - case 'AccountsController:getSelectedAccount': - return selectedAccount; - case 'AccountTreeController:getAccountsFromSelectedAccountGroup': - return [groupedAccount]; - default: - throw new Error(`Unexpected action: ${actionType}`); - } - } - const messenger = { call }; - - expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ - address: SELECTED_ADDRESS, - }); - }); - - it('falls back to the selected account group when selected account lookup is unavailable', () => { - const groupedAccount = buildEvmAccount(GROUP_ADDRESS, 'grouped'); - function call(actionType: string): InternalAccount | InternalAccount[] { - switch (actionType) { - case 'AccountsController:getSelectedAccount': - throw new Error('Selected account unavailable'); - case 'AccountTreeController:getAccountsFromSelectedAccountGroup': - return [groupedAccount]; - default: - throw new Error(`Unexpected action: ${actionType}`); - } - } - const messenger = { call }; - - expect(getSelectedEvmAccountFromMessenger(messenger)).toStrictEqual({ - address: GROUP_ADDRESS, - }); - }); -}); diff --git a/packages/perps-controller/src/utils/accountUtils.ts b/packages/perps-controller/src/utils/accountUtils.ts index 737526df90..747cdd7620 100644 --- a/packages/perps-controller/src/utils/accountUtils.ts +++ b/packages/perps-controller/src/utils/accountUtils.ts @@ -5,7 +5,6 @@ import type { InternalAccount } from '@metamask/keyring-internal-api'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import type { AccountState, PerpsInternalAccount } from '../types'; import type { SpotClearinghouseStateResponse } from '../types/hyperliquid-types'; @@ -38,32 +37,6 @@ export function getSelectedEvmAccount( return getEvmAccountFromAccountGroup(accounts); } -export function getSelectedEvmAccountFromMessenger( - messenger: Pick, -): { address: string } | undefined { - try { - const selectedAccount = messenger.call( - 'AccountsController:getSelectedAccount', - ); - const evmAccount = findEvmAccount([selectedAccount]); - if (evmAccount) { - return { address: evmAccount.address }; - } - } catch { - // Fall back to the selected account group if the direct lookup is unavailable. - } - - try { - return getSelectedEvmAccount( - messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ), - ); - } catch { - return undefined; - } -} - export type ReturnOnEquityInput = { unrealizedPnl: string | number; returnOnEquity: string | number; diff --git a/yarn.lock b/yarn.lock index 2d0e174823..d177fa888e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,7 +4979,6 @@ __metadata: dependencies: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-tree-controller": "npm:^7.4.0" - "@metamask/accounts-controller": "npm:^38.1.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.1.0"