Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@
/packages/profile-metrics-controller @MetaMask/mobile-platform @MetaMask/extension-platform

## Initialization
/packages/wallet/src/initialization/instances/approval-controller/ @MetaMask/confirmations
/packages/wallet/src/initialization/instances/keyring-controller.ts @MetaMask/accounts-engineers @MetaMask/core-platform

## Package Release related
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -567,7 +567,9 @@ linkStyle default opacity:0.5
user_operation_controller --> polling_controller;
user_operation_controller --> transaction_controller;
user_operation_controller --> eth_block_tracker;
wallet --> approval_controller;
wallet --> base_controller;
wallet --> controller_utils;
wallet --> keyring_controller;
wallet --> messenger;
wallet --> storage_service;
Expand Down
6 changes: 6 additions & 0 deletions packages/wallet/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- **BREAKING:** Wire `ApprovalController` into the default wallet initialization ([#8953](https://github.com/MetaMask/core/pull/8953))
- The default `Wallet` now constructs an `ApprovalController` and registers its `ApprovalController:*` messenger actions. Consumers that pass their own `messenger` and already wire an `ApprovalController` must remove their own before upgrading, or the duplicate registration will collide.
- Adds an `approvalController` slot to `instanceOptions` with `showApprovalRequest` (the callback that surfaces pending approval requests to the user; defaults to a no-op) and `typesExcludedFromRateLimiting` (the approval types exempt from per-origin rate limiting; defaults to a baseline of EVM approval types). Both let consumers (extension, mobile, wallet-cli) inject their platform-specific values.

## [2.0.0]

### Added
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,10 @@
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@metamask/approval-controller": "^9.0.1",
"@metamask/base-controller": "^9.1.0",
"@metamask/browser-passworder": "^6.0.0",
"@metamask/controller-utils": "^12.1.0",
"@metamask/keyring-controller": "^26.0.0",
"@metamask/messenger": "^1.2.0",
"@metamask/scure-bip39": "^2.1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { ApprovalController } from '@metamask/approval-controller';
import { ApprovalType } from '@metamask/controller-utils';
import { Messenger } from '@metamask/messenger';

import { defaultConfigurations } from '../../defaults';
import type {
DefaultActions,
DefaultEvents,
RootMessenger,
} from '../../defaults';
import { approvalController } from './approval-controller';

/**
* Creates a root messenger for use in tests.
*
* @returns A root messenger.
*/
function getRootMessenger(): RootMessenger<DefaultActions, DefaultEvents> {
return new Messenger({ namespace: 'Root' });
}

describe('approvalController', () => {
it('is registered as a default initialization configuration', () => {
// Proves the controller is part of the default ensemble that `initialize()`
// wires, without constructing a `Wallet` (which keeps this PR independent of
// the constructor-options shape).
expect(Object.values(defaultConfigurations)).toContain(approvalController);
});

it('initializes an ApprovalController with default state', () => {
const messenger = approvalController.getMessenger(getRootMessenger());

const instance = approvalController.init({
state: undefined,
messenger,
options: {},
});

expect(instance).toBeInstanceOf(ApprovalController);
expect(instance.state).toStrictEqual({
pendingApprovals: {},
pendingApprovalCount: 0,
approvalFlows: [],
});
});

it('forwards the provided state to the controller', () => {
const messenger = approvalController.getMessenger(getRootMessenger());

const instance = approvalController.init({
state: {
pendingApprovals: {},
pendingApprovalCount: 3,
approvalFlows: [],
},
messenger,
options: {},
});

expect(instance.state.pendingApprovalCount).toBe(3);
});

it('uses the provided showApprovalRequest callback', () => {
const messenger = approvalController.getMessenger(getRootMessenger());
const showApprovalRequest = jest.fn();

const instance = approvalController.init({
state: undefined,
messenger,
options: { showApprovalRequest },
});

instance.startFlow();

expect(showApprovalRequest).toHaveBeenCalledTimes(1);
});

it('defaults showApprovalRequest to a no-op when omitted', () => {
const messenger = approvalController.getMessenger(getRootMessenger());

const instance = approvalController.init({
state: undefined,
messenger,
options: {},
});

expect(() => instance.startFlow()).not.toThrow();
});

// Pins the exact default exclusion set (independent of the source constant):
// each of these types must allow multiple pending requests from one origin.
// The pending promises never settle here; `.catch` only marks them handled.
it.each([
ApprovalType.PersonalSign,
ApprovalType.EthSignTypedData,
ApprovalType.Transaction,
ApprovalType.WatchAsset,
ApprovalType.EthGetEncryptionPublicKey,
ApprovalType.EthDecrypt,
])('excludes %s from rate limiting by default', (type) => {
const messenger = approvalController.getMessenger(getRootMessenger());

const instance = approvalController.init({
state: undefined,
messenger,
options: {},
});

instance.add({ origin: 'metamask.io', type }).catch(() => undefined);
instance.add({ origin: 'metamask.io', type }).catch(() => undefined);

// Both requests are queued rather than the second being rejected.
expect(instance.state.pendingApprovalCount).toBe(2);
});

it('honors a custom typesExcludedFromRateLimiting list that overrides the default', () => {
const messenger = approvalController.getMessenger(getRootMessenger());

const instance = approvalController.init({
state: undefined,
messenger,
// Empty override: nothing is excluded, not even the default types.
options: { typesExcludedFromRateLimiting: [] },
});

instance
.add({ origin: 'metamask.io', type: ApprovalType.Transaction })
.catch(() => undefined);

// A second request of the same origin and type is now rate-limited.
expect(() =>
instance.add({ origin: 'metamask.io', type: ApprovalType.Transaction }),
).toThrow('already pending');
});

it('exposes its actions through the root messenger', () => {
const rootMessenger = getRootMessenger();
const messenger = approvalController.getMessenger(rootMessenger);

approvalController.init({ state: undefined, messenger, options: {} });

expect(rootMessenger.call('ApprovalController:getState')).toStrictEqual({
pendingApprovals: {},
pendingApprovalCount: 0,
approvalFlows: [],
});
});
});
Comment thread
sirtimid marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
ApprovalController,
ApprovalControllerMessenger,
} from '@metamask/approval-controller';
import { ApprovalType } from '@metamask/controller-utils';
import { Messenger } from '@metamask/messenger';

import { InitializationConfiguration } from '../../types';

/**
* Approval types that are exempt from per-origin rate limiting, so more than one
* request of the same type can be pending at once. These are the common EVM
* types: signing, transactions, watch-asset, and encryption.
*
* Clients can replace this list via
* `instanceOptions.approvalController.typesExcludedFromRateLimiting` — the
* extension and mobile pass their own. `snap_dialog` will be added here once the
* wallet wires `SnapController`.
*/
const DEFAULT_TYPES_EXCLUDED_FROM_RATE_LIMITING = [
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

How did you pick these?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

These are the six EVM ApprovalType values carried over from the old-design wallet port (origin/feat/wallet-library), which mirrored the extension's exclusion list. They're also the superset of what the clients exclude today: both extension and mobile exempt Transaction and WatchAsset; the extension additionally exempts the signing (personal_sign, eth_signTypedData) and encryption (eth_getEncryptionPublicKey, eth_decrypt) approvals.

I used that as the platform-agnostic baseline and dropped the two client-specific string entries — the smart-transaction status type (whose value even differs between extension and mobile) and snap_dialog (which only matters once SnapController is wired) — making the whole list overridable via instanceOptions.approvalController.typesExcludedFromRateLimiting instead.

Happy to trim the default to just the intersection (Transaction + WatchAsset) if you'd prefer a more conservative baseline.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's have someone from the confirmations team sanity check that 👍

Copy link
Copy Markdown
Member

@matthewwalsh0 matthewwalsh0 Jun 2, 2026

Choose a reason for hiding this comment

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

Looks fine to me, as long as it's the superset of the two, we were meaning to align mobile with extension.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would add snap_dialog if we are going for a superset default.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Though I guess the problem with that would be the client specific smart TX string

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thought about this more and kept the default as the EVM baseline (no snap_dialog). The reasoning: the default only applies to consumers that don't pass typesExcludedFromRateLimiting, and both extension and mobile pass their own full lists — so the default isn't what aligns the clients (that happens via each client's override at adoption time). That means a superset default doesn't really buy us anything, and it can't be a true superset anyway because of the smart-TX string divergence you flagged. So the default stays minimal; snap_dialog joins it when we wire SnapController (nothing emits it until then), and clients still get exact parity via the override. Happy to add it back if you'd still prefer the superset.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Ideally we didn't have the divergence in the smart TX string, then we could use a superset and not have overrides.

But up to @matthewwalsh0 whether he thinks that is worth the trouble right now.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We can double-check both clients and remove in future once we know the superset is safe?

ApprovalType.PersonalSign,
ApprovalType.EthSignTypedData,
ApprovalType.Transaction,
ApprovalType.WatchAsset,
ApprovalType.EthGetEncryptionPublicKey,
ApprovalType.EthDecrypt,
];
Comment thread
cursor[bot] marked this conversation as resolved.

export const approvalController: InitializationConfiguration<
ApprovalController,
ApprovalControllerMessenger
> = {
name: 'ApprovalController',
init: ({ state, messenger, options }) =>
new ApprovalController({
state,
messenger,
showApprovalRequest:
options.showApprovalRequest ?? ((): void => undefined),
typesExcludedFromRateLimiting:
options.typesExcludedFromRateLimiting ??
DEFAULT_TYPES_EXCLUDED_FROM_RATE_LIMITING,
}),
getMessenger: (parent) =>
new Messenger({
namespace: 'ApprovalController',
parent,
}),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type {
ApprovalControllerOptions,
ShowApprovalRequest,
} from '@metamask/approval-controller';

/**
* Per-instance options for the wallet's `ApprovalController`. Both fields are
* optional; see the controller's `init` for the defaults applied when omitted.
*/
export type ApprovalControllerInstanceOptions = {
/**
* Callback that surfaces a pending approval request to the user. Defaults to
* a no-op so the controller works headlessly.
*/
showApprovalRequest?: ShowApprovalRequest;
/**
* Approval types exempt from per-origin rate limiting. Defaults to a baseline
* of EVM approval types.
*/
typesExcludedFromRateLimiting?: NonNullable<
ApprovalControllerOptions['typesExcludedFromRateLimiting']
>;
};
1 change: 1 addition & 0 deletions packages/wallet/src/initialization/instances/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export { approvalController } from './approval-controller/approval-controller';
export { keyringController } from './keyring-controller';
export { storageService } from './storage-service';
2 changes: 2 additions & 0 deletions packages/wallet/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
DefaultEvents,
RootMessenger,
} from './initialization/defaults';
import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types';
import { GenericEncryptor } from './initialization/instances/keyring-controller';
import { InitializationConfiguration } from './initialization/types';

Expand All @@ -21,6 +22,7 @@ export type WalletOptions = {
};

export type InstanceSpecificOptions = {
approvalController?: ApprovalControllerInstanceOptions;
keyringController?: {
encryptor?: GenericEncryptor;
keyringBuilders?: KeyringControllerOptions['keyringBuilders'];
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
"rootDir": "./src"
},
"references": [
{ "path": "../approval-controller/tsconfig.build.json" },
{ "path": "../base-controller/tsconfig.build.json" },
{ "path": "../controller-utils/tsconfig.build.json" },
{ "path": "../keyring-controller/tsconfig.build.json" },
{ "path": "../messenger/tsconfig.build.json" },
{ "path": "../storage-service/tsconfig.build.json" }
Expand Down
2 changes: 2 additions & 0 deletions packages/wallet/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"baseUrl": "./"
},
"references": [
{ "path": "../approval-controller/tsconfig.json" },
{ "path": "../base-controller/tsconfig.json" },
{ "path": "../controller-utils/tsconfig.json" },
{ "path": "../keyring-controller/tsconfig.json" },
{ "path": "../messenger/tsconfig.json" },
{ "path": "../storage-service/tsconfig.json" }
Expand Down
2 changes: 2 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5885,9 +5885,11 @@ __metadata:
version: 0.0.0-use.local
resolution: "@metamask/wallet@workspace:packages/wallet"
dependencies:
"@metamask/approval-controller": "npm:^9.0.1"
"@metamask/auto-changelog": "npm:^6.1.0"
"@metamask/base-controller": "npm:^9.1.0"
"@metamask/browser-passworder": "npm:^6.0.0"
"@metamask/controller-utils": "npm:^12.1.0"
"@metamask/keyring-controller": "npm:^26.0.0"
"@metamask/messenger": "npm:^1.2.0"
"@metamask/scure-bip39": "npm:^2.1.1"
Expand Down
Loading