Skip to content

Conversation

satyajeetkolhapure
Copy link

@satyajeetkolhapure satyajeetkolhapure commented Sep 8, 2025

Explanation

This pull request adds Rewards Controller and messenger feature, which is used for silent login in the MetaMask Mobile app. The implementation enables automatic authentication for users when they switch accounts or unlock their wallet.

The RewardsController should:

Automatically authenticates a user through a silent signature from KeyringController if their EVM address owns a rewards profile
Skips auth for Solana and hardware wallets for now
Skips auth for 10 minutes of grace period

Note: This controller was part of metamask-mobile and moved to core as it can be used for metamask-extension too.

References

Checklist

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

satyajeetkolhapure and others added 30 commits September 8, 2025 12:46
@satyajeetkolhapure satyajeetkolhapure self-assigned this Sep 10, 2025
@satyajeetkolhapure satyajeetkolhapure marked this pull request as ready for review September 10, 2025 14:54
@satyajeetkolhapure satyajeetkolhapure requested a review from a team as a code owner September 10, 2025 14:54
cursor[bot]

This comment was marked as outdated.

jbblanc
jbblanc previously approved these changes Sep 10, 2025
Copy link
Contributor

@GuillaumeRx GuillaumeRx left a comment

Choose a reason for hiding this comment

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

In addition to the requested changes:

  • please review the spacing inside the functions, it's a bit out of place

  • There's a significant amount of logging, especially in catch blocks. I wonder if we can reduce the logging and throw regular errors instead.

Comment on lines 504 to 528
try {
// Generate timestamp and sign the message
const timestamp = Math.floor(Date.now() / 1000);

let signature;
try {
signature = await this.#signRewardsMessage(internalAccount, timestamp);
} catch (signError) {
log('RewardsController: Failed to generate signature:', signError);

// Check if the error is due to locked keyring
if (
signError &&
typeof signError === 'object' &&
'message' in signError
) {
const errorMessage = (signError as Error).message;
if (errorMessage.includes('controller is locked')) {
log('RewardsController: Keyring is locked, skipping silent auth');
return; // Exit silently when keyring is locked
}
}

throw signError;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Can we avoid the nested try...catch blocks ?

Copy link
Author

Choose a reason for hiding this comment

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

fixed

Copy link
Contributor

Choose a reason for hiding this comment

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

Not fixed


constructor({
messenger,
fetch: fetchFunction,
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we have to pass fetch in the constructor ?

Copy link
Author

Choose a reason for hiding this comment

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

Actually the fetch is just a function for handing the fetch process and url is formed in a function makeRequest.
As in rewards case the fetch handling is similar for all calls we are sending a fetch implementation in the constructor.

Copy link
Contributor

Choose a reason for hiding this comment

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

There's no need to pass fetch as it's already present as a global in the versions of node.js we use. I don't fully get your comment, it doesn't answer my question.

@GuillaumeRx
Copy link
Contributor

My review was an initial pass on this PR, do not expect it to be a thorough review as this PR is pretty big.

In the future please try to keep the PR size down. This could have been split up in multiple PRs.

cursor[bot]

This comment was marked as outdated.

Copy link
Contributor

@GuillaumeRx GuillaumeRx left a comment

Choose a reason for hiding this comment

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

In addition, there's still a very significant amount of logging in errors

isHardwareAccount: boolean,
): boolean {
// Skip for hardware and Solana accounts
if (isHardwareAccount || isSolanaAddress(address)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There's no need to bring a package (@solana/addresses) to validate an address with isSolanaAddress as the CAIP-10 ID contains the chain ID that could be use to determine if it's a solana account.

Copy link
Contributor

Choose a reason for hiding this comment

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

You shouldn't be completely mocking the messenger but rather creating a real restricted messenger with mocked actions inside it

Comment on lines +134 to +143
mockMessenger = {
subscribe: jest.fn(),
call: jest.fn(),
registerActionHandler: jest.fn(),
unregisterActionHandler: jest.fn(),
publish: jest.fn(),
clearEventSubscriptions: jest.fn(),
registerInitialEventPayload: jest.fn(),
unsubscribe: jest.fn(),
} as unknown as jest.Mocked<RewardsControllerMessenger>;
Copy link
Contributor

Choose a reason for hiding this comment

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

You shouldn't be mocking completely the messenger but rather creating a real messenger with mocked actions inside it. You can look at other controllers to see how it's done.

} as unknown as jest.Mocked<RewardsControllerMessenger>;

// Reset feature flag to enabled by default
mockGetRewardsFeatureFlag.mockReturnValue(true);
Copy link
Contributor

Choose a reason for hiding this comment

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

We should get rid of this mock and instead do a partial implementation of the feature flag action in the test messenger.

Copy link
Contributor

Choose a reason for hiding this comment

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

This can be deleted now that it exists in the controller file

__logSpy: jest.Mock;
};

let mockMessenger: jest.Mocked<RewardsDataServiceMessenger>;
Copy link
Contributor

Choose a reason for hiding this comment

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

We shouldn't mock the entire messenger.

Comment on lines +78 to +82
fetchImpl = jest
.fn()
.mockResolvedValue(
okJsonResponse({ sessionId: 'sess', subscription: { id: 'sub' } }),
),
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't manually mock fetch, we rely on nock to provide fetch mocks.

Comment on lines +102 to +105
mockMessenger = {
registerActionHandler: jest.fn(),
call: jest.fn(),
} as unknown as jest.Mocked<RewardsDataServiceMessenger>;
Copy link
Contributor

Choose a reason for hiding this comment

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

We don't directly mock the messenger


constructor({
messenger,
fetch: fetchFunction,
Copy link
Contributor

Choose a reason for hiding this comment

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

There's no need to pass fetch as it's already present as a global in the versions of node.js we use. I don't fully get your comment, it doesn't answer my question.


readonly #environment: EnvironmentType;

readonly #getSubscriptionToken: GetSubscriptionToken;
Copy link
Contributor

Choose a reason for hiding this comment

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

Where does this function comes from ? If it's available through a messenger action please use the messenger rather than passing it as a parameter.

lastPerpsDiscountRateFetched: number | null;
};

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
Copy link
Member

@Gudahtt Gudahtt Sep 11, 2025

Choose a reason for hiding this comment

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

This comment seems to be unnecessary, there is no lint error here

};

// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
export type RewardsControllerState = {
Copy link
Member

Choose a reason for hiding this comment

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

Could you add descriptions to these properties? It's hard to tell what they are for

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants