From 89b07ea4c19d7b13e3d34bcd97504e23bbc5a8a5 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Fri, 26 Sep 2025 21:13:24 -0400 Subject: [PATCH 1/7] Add test coverage --- .../RewardsController.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 86c93c554a7c..7641d7cbdec3 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -8532,4 +8532,134 @@ describe('RewardsController', () => { expect(mockIsSolanaAddress).not.toHaveBeenCalled(); }); }); + + describe('Silent Authentication Flow', () => { + let mockMessenger: jest.Mocked; + let subscribeCallback: (() => void) | undefined; + + const mockInternalAccount: InternalAccount = { + id: 'mock-id', + address: '0x123', + options: {}, + methods: [], + type: 'eip155:eoa', + metadata: { + name: 'mock-account', + keyring: { + type: 'HD Key Tree', + }, + importTime: 0, + }, + scopes: [], + }; + + let originalDateNow: () => number; + + beforeEach(() => { + mockMessenger = { + call: jest.fn(), + subscribe: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + publish: jest.fn(), + unsubscribe: jest.fn(), + clearEventSubscriptions: jest.fn(), + unregisterActionHandler: jest.fn(), + } as unknown as jest.Mocked; + + // Mock Date.now to return a consistent timestamp + originalDateNow = Date.now; + Date.now = jest.fn().mockReturnValue(1000000); + + // Mock RewardsDataService:login to route through our mockRewardsDataService.getJwt + mockMessenger.call.mockImplementation(((...args: any[]) => { + const [method, payload] = args; + if (method === 'AccountsController:getSelectedMultichainAccount') { + return Promise.resolve(mockInternalAccount); + } + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xmockSignature'); + } + if (method === 'RewardsDataService:login') { + // Mimic the controller calling into data service; delegate to getJwt + const { timestamp, signature } = payload || {}; + // Make sure to pass the account address correctly + return mockRewardsDataService + .getJwt(signature, timestamp, mockInternalAccount.address) + .then((jwt: string) => ({ + subscription: { id: 'sub-123' }, + sessionId: jwt, + })); + } + return Promise.resolve(undefined); + }) as any); + + new RewardsController({ + messenger: mockMessenger, + state: getRewardsControllerDefaultState(), + }); + + // Find the callback function for the 'AccountsController:selectedAccountChange' event + subscribeCallback = mockMessenger.subscribe.mock.calls.find( + (call) => call[0] === 'AccountsController:selectedAccountChange', + )?.[1] as () => void; + }); + + afterEach(() => { + // Restore original Date.now + Date.now = originalDateNow; + }); + + it('should retry silent auth with server timestamp on InvalidTimestampError', async () => { + // Import the actual InvalidTimestampError + const { InvalidTimestampError } = jest.requireActual( + './services/rewards-data-service', + ); + const mockError = new InvalidTimestampError('Invalid timestamp', 12345); + const mockJwt = 'mock-jwt'; + + // Clear previous calls + mockRewardsDataService.getJwt.mockClear(); + + // First call fails with timestamp error, second call succeeds + mockRewardsDataService.getJwt + .mockImplementationOnce(() => Promise.reject(mockError)) + .mockImplementationOnce(() => Promise.resolve(mockJwt)); + + // Trigger the silent auth flow + subscribeCallback?.(); + + // Let the event loop run to allow promises to resolve + await new Promise(process.nextTick); + + expect(mockRewardsDataService.getJwt).toHaveBeenCalledTimes(2); + expect(mockRewardsDataService.getJwt).toHaveBeenCalledWith( + expect.any(String), + expect.any(Number), + mockInternalAccount.address, + ); + expect(mockRewardsDataService.getJwt).toHaveBeenCalledWith( + expect.any(String), + mockError.timestamp, + mockInternalAccount.address, + ); + }); + + it('should not retry silent auth if error is not InvalidTimestampError', async () => { + const mockError = new Error('Some other error'); + + // Clear previous calls + mockRewardsDataService.getJwt.mockClear(); + // Mock to reject with a non-InvalidTimestampError + mockRewardsDataService.getJwt.mockRejectedValueOnce(mockError); + + // Trigger the silent auth flow + subscribeCallback?.(); + + // Let the event loop run + await new Promise(process.nextTick); + + expect(mockRewardsDataService.getJwt).toHaveBeenCalledTimes(1); + }); + }); }); From a9c7dfc4b12ce1621c2ec2dbc50fda2369ba997d Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Fri, 26 Sep 2025 22:03:27 -0400 Subject: [PATCH 2/7] Update RewardsController.test.ts --- .../RewardsController.test.ts | 130 ------------------ 1 file changed, 130 deletions(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 7641d7cbdec3..86c93c554a7c 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -8532,134 +8532,4 @@ describe('RewardsController', () => { expect(mockIsSolanaAddress).not.toHaveBeenCalled(); }); }); - - describe('Silent Authentication Flow', () => { - let mockMessenger: jest.Mocked; - let subscribeCallback: (() => void) | undefined; - - const mockInternalAccount: InternalAccount = { - id: 'mock-id', - address: '0x123', - options: {}, - methods: [], - type: 'eip155:eoa', - metadata: { - name: 'mock-account', - keyring: { - type: 'HD Key Tree', - }, - importTime: 0, - }, - scopes: [], - }; - - let originalDateNow: () => number; - - beforeEach(() => { - mockMessenger = { - call: jest.fn(), - subscribe: jest.fn(), - registerActionHandler: jest.fn(), - registerInitialEventPayload: jest.fn(), - publish: jest.fn(), - unsubscribe: jest.fn(), - clearEventSubscriptions: jest.fn(), - unregisterActionHandler: jest.fn(), - } as unknown as jest.Mocked; - - // Mock Date.now to return a consistent timestamp - originalDateNow = Date.now; - Date.now = jest.fn().mockReturnValue(1000000); - - // Mock RewardsDataService:login to route through our mockRewardsDataService.getJwt - mockMessenger.call.mockImplementation(((...args: any[]) => { - const [method, payload] = args; - if (method === 'AccountsController:getSelectedMultichainAccount') { - return Promise.resolve(mockInternalAccount); - } - if (method === 'KeyringController:signPersonalMessage') { - return Promise.resolve('0xmockSignature'); - } - if (method === 'RewardsDataService:login') { - // Mimic the controller calling into data service; delegate to getJwt - const { timestamp, signature } = payload || {}; - // Make sure to pass the account address correctly - return mockRewardsDataService - .getJwt(signature, timestamp, mockInternalAccount.address) - .then((jwt: string) => ({ - subscription: { id: 'sub-123' }, - sessionId: jwt, - })); - } - return Promise.resolve(undefined); - }) as any); - - new RewardsController({ - messenger: mockMessenger, - state: getRewardsControllerDefaultState(), - }); - - // Find the callback function for the 'AccountsController:selectedAccountChange' event - subscribeCallback = mockMessenger.subscribe.mock.calls.find( - (call) => call[0] === 'AccountsController:selectedAccountChange', - )?.[1] as () => void; - }); - - afterEach(() => { - // Restore original Date.now - Date.now = originalDateNow; - }); - - it('should retry silent auth with server timestamp on InvalidTimestampError', async () => { - // Import the actual InvalidTimestampError - const { InvalidTimestampError } = jest.requireActual( - './services/rewards-data-service', - ); - const mockError = new InvalidTimestampError('Invalid timestamp', 12345); - const mockJwt = 'mock-jwt'; - - // Clear previous calls - mockRewardsDataService.getJwt.mockClear(); - - // First call fails with timestamp error, second call succeeds - mockRewardsDataService.getJwt - .mockImplementationOnce(() => Promise.reject(mockError)) - .mockImplementationOnce(() => Promise.resolve(mockJwt)); - - // Trigger the silent auth flow - subscribeCallback?.(); - - // Let the event loop run to allow promises to resolve - await new Promise(process.nextTick); - - expect(mockRewardsDataService.getJwt).toHaveBeenCalledTimes(2); - expect(mockRewardsDataService.getJwt).toHaveBeenCalledWith( - expect.any(String), - expect.any(Number), - mockInternalAccount.address, - ); - expect(mockRewardsDataService.getJwt).toHaveBeenCalledWith( - expect.any(String), - mockError.timestamp, - mockInternalAccount.address, - ); - }); - - it('should not retry silent auth if error is not InvalidTimestampError', async () => { - const mockError = new Error('Some other error'); - - // Clear previous calls - mockRewardsDataService.getJwt.mockClear(); - // Mock to reject with a non-InvalidTimestampError - mockRewardsDataService.getJwt.mockRejectedValueOnce(mockError); - - // Trigger the silent auth flow - subscribeCallback?.(); - - // Let the event loop run - await new Promise(process.nextTick); - - expect(mockRewardsDataService.getJwt).toHaveBeenCalledTimes(1); - }); - }); }); From 42359928a543d7c6f15cc15a9d54f6c9b4b36315 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 29 Sep 2025 16:55:07 -0400 Subject: [PATCH 3/7] Switch to mobile optin endpoint --- .../RewardsController.test.ts | 271 ++++++++++++------ .../rewards-controller/RewardsController.ts | 71 ++--- .../rewards-controller/services/index.ts | 3 +- .../services/rewards-data-service.test.ts | 73 +++-- .../services/rewards-data-service.ts | 53 +--- .../controllers/rewards-controller/types.ts | 40 +-- .../rewards-controller-messenger/index.ts | 9 +- 7 files changed, 309 insertions(+), 211 deletions(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 86c93c554a7c..ad7a6c03ad5c 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3021,7 +3021,7 @@ describe('RewardsController', () => { }); describe('optIn', () => { - const mockInternalAccount = { + const mockEvmInternalAccount = { address: '0x123456789', type: 'eip155:eoa' as const, id: 'test-id', @@ -3029,7 +3029,21 @@ describe('RewardsController', () => { options: {}, methods: ['personal_sign'], metadata: { - name: 'Test Account', + name: 'Test EVM Account', + keyring: { type: 'HD Key Tree' }, + importTime: Date.now(), + }, + } as InternalAccount; + + const mockSolanaInternalAccount = { + address: 'solanaAddress123', + type: 'solana:data-account' as const, + id: 'solana-test-id', + scopes: ['solana:data-account' as const], + options: {}, + methods: ['signMessage'], + metadata: { + name: 'Test Solana Account', keyring: { type: 'HD Key Tree' }, importTime: Date.now(), }, @@ -3042,68 +3056,137 @@ describe('RewardsController', () => { it('should skip opt-in when feature flag is disabled', async () => { // Arrange mockSelectRewardsEnabledFlag.mockReturnValue(false); + // Clear any previous calls + mockMessenger.call.mockClear(); // Act - await controller.optIn(mockInternalAccount); + const result = await controller.optIn(mockEvmInternalAccount); - // Assert - Should not call generateChallenge, signPersonalMessage, or optin - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:generateChallenge', + // Assert + expect(result).toBeNull(); + expect(mockLogger.log).toHaveBeenCalledWith( + 'RewardsController: Rewards feature is disabled, skipping optin', expect.anything(), ); - expect(mockMessenger.call).not.toHaveBeenCalledWith( + // Now verify no calls are made + expect(mockMessenger.call).not.toHaveBeenCalled(); + }); + + it('should successfully opt-in with an EVM account', async () => { + // Arrange + const mockSubscriptionId = 'test-subscription-id'; + const mockOptinResponse = { + subscription: { id: mockSubscriptionId }, + sessionId: 'test-session-id', + }; + + // Mock the messaging system calls + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:mobileOptin') { + const { account, signature, timestamp } = _args[0] as any; + expect(account).toBe(mockEvmInternalAccount.address); + expect(signature).toBeDefined(); + expect(timestamp).toBeDefined(); + return Promise.resolve(mockOptinResponse); + } else if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature'); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.optIn(mockEvmInternalAccount); + + // Assert + expect(result).toBe(mockSubscriptionId); + expect(mockMessenger.call).toHaveBeenCalledWith( 'KeyringController:signPersonalMessage', - expect.anything(), + expect.objectContaining({ + from: mockEvmInternalAccount.address, + }), ); - expect(mockMessenger.call).not.toHaveBeenCalledWith( - 'RewardsDataService:optin', - expect.anything(), + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:mobileOptin', + expect.objectContaining({ + account: mockEvmInternalAccount.address, + }), ); }); - it('should handle signature generation errors', async () => { + it('should successfully opt-in with a Solana account', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', + const mockSubscriptionId = 'solana-subscription-id'; + const mockOptinResponse = { + subscription: { id: mockSubscriptionId }, + sessionId: 'test-session-id', }; - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) - .mockRejectedValueOnce(new Error('Signature failed')); + // Mock the messaging system calls + mockMessenger.call.mockImplementation((method, ..._args): any => { + const params = _args[0] as any; + if (method === 'RewardsDataService:mobileOptin') { + // Don't verify signature here as it might not be set yet + expect(params.account).toBe(mockSolanaInternalAccount.address); + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.optIn(mockSolanaInternalAccount); + + // Assert + expect(result).toBe(mockSubscriptionId); + // We're not actually calling SolanaWalletSnapController:signMessage in the implementation + // but rather KeyringController:signPersonalMessage, so adjust the assertion + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ + from: mockSolanaInternalAccount.address, + }), + ); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:mobileOptin', + expect.objectContaining({ + account: mockSolanaInternalAccount.address, + }), + ); + }); + + it('should handle signature generation errors', async () => { + // Arrange + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.reject(new Error('Signature failed')); + } + return Promise.resolve(); + }); // Act & Assert - await expect(controller.optIn(mockInternalAccount)).rejects.toThrow( + await expect(controller.optIn(mockEvmInternalAccount)).rejects.toThrow( 'Signature failed', ); }); it('should handle optin service errors', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; - - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) - .mockResolvedValueOnce(mockSignature) - .mockRejectedValueOnce(new Error('Optin failed')); + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.reject(new Error('Optin failed')); + } + return Promise.resolve(); + }); // Act & Assert - await expect(controller.optIn(mockInternalAccount)).rejects.toThrow( + await expect(controller.optIn(mockEvmInternalAccount)).rejects.toThrow( 'Optin failed', ); }); it('should use Buffer fallback when toHex fails during hex message conversion', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge with special chars: éñü', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { sessionId: 'session-456', subscription: { @@ -3118,38 +3201,33 @@ describe('RewardsController', () => { throw new Error('toHex encoding error'); }); - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + // Mock the challenge response + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + // We don't need to verify the exact data here, just that it was called + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve(); + }); // Act - await controller.optIn(mockInternalAccount); + await controller.optIn(mockEvmInternalAccount); - // Assert - expect(mockToHex).toHaveBeenCalledWith(mockChallengeResponse.message); - - // Verify the fallback Buffer conversion was used by checking the hex data passed to signing - const expectedBufferHex = - '0x' + - Buffer.from(mockChallengeResponse.message, 'utf8').toString('hex'); - expect(mockMessenger.call).toHaveBeenNthCalledWith( - 3, + // Assert - we know toHex was called and threw an error, so we don't need to verify the exact parameters + + // Just verify the call was made with the right method and address + expect(mockMessenger.call).toHaveBeenCalledWith( 'KeyringController:signPersonalMessage', - { - data: expectedBufferHex, - from: mockInternalAccount.address, - }, + expect.objectContaining({ + from: mockEvmInternalAccount.address, + }), ); }); it('should store subscription token when optin response has subscription id and session id', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { sessionId: 'session-456', subscription: { @@ -3159,15 +3237,22 @@ describe('RewardsController', () => { }, }; - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve({ + id: 'challenge-123', + message: 'test challenge message', + }); + }); mockStoreSubscriptionToken.mockResolvedValue({ success: true }); // Act - const result = await controller.optIn(mockInternalAccount); + const result = await controller.optIn(mockEvmInternalAccount); // Assert expect(mockStoreSubscriptionToken).toHaveBeenCalledWith( @@ -3179,11 +3264,6 @@ describe('RewardsController', () => { it('should handle storeSubscriptionToken errors gracefully without throwing', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { sessionId: 'session-456', subscription: { @@ -3193,16 +3273,23 @@ describe('RewardsController', () => { }, }; - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve({ + id: 'challenge-123', + message: 'test challenge message', + }); + }); const mockError = new Error('Token storage failed'); mockStoreSubscriptionToken.mockRejectedValue(mockError); // Act - const result = await controller.optIn(mockInternalAccount); + const result = await controller.optIn(mockEvmInternalAccount); // Assert expect(mockStoreSubscriptionToken).toHaveBeenCalledWith( @@ -3219,11 +3306,6 @@ describe('RewardsController', () => { it('should not store subscription token when optin response lacks subscription id', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { sessionId: 'session-456', subscription: { @@ -3233,13 +3315,17 @@ describe('RewardsController', () => { }, } as any; // Type assertion to allow incomplete response for testing - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve(); + }); // Act - const result = await controller.optIn(mockInternalAccount); + const result = await controller.optIn(mockEvmInternalAccount); // Assert expect(mockStoreSubscriptionToken).not.toHaveBeenCalled(); @@ -3248,11 +3334,6 @@ describe('RewardsController', () => { it('should not store subscription token when optin response lacks session id', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { // Missing sessionId subscription: { @@ -3262,13 +3343,17 @@ describe('RewardsController', () => { }, } as any; // Type assertion to allow incomplete response for testing - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature123'); + } else if (method === 'RewardsDataService:mobileOptin') { + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve(); + }); // Act - const result = await controller.optIn(mockInternalAccount); + const result = await controller.optIn(mockEvmInternalAccount); // Assert expect(mockStoreSubscriptionToken).not.toHaveBeenCalled(); diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 172d6dfd8a13..817550a3ffa5 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -1,5 +1,4 @@ import { BaseController } from '@metamask/base-controller'; -import { toHex } from '@metamask/controller-utils'; import { maxBy } from 'lodash'; import { type RewardsControllerState, @@ -1335,42 +1334,44 @@ export class RewardsController extends BaseController< account: account.address, }); - const challengeResponse = await this.messagingSystem.call( - 'RewardsDataService:generateChallenge', - { - address: account.address, - }, - ); + // Generate timestamp and sign the message for mobile optin + let timestamp = Math.floor(Date.now() / 1000); + let signature = await this.#signRewardsMessage(account, timestamp); + let retryAttempt = 0; + const MAX_RETRY_ATTEMPTS = 1; - // Try different encoding approaches to handle potential character issues - let hexMessage; - try { - // First try: direct toHex conversion - hexMessage = toHex(challengeResponse.message); - } catch { - // Fallback: use Buffer to convert to hex if toHex fails - hexMessage = - '0x' + Buffer.from(challengeResponse.message, 'utf8').toString('hex'); - } - - // Use KeyringController for silent signature - const signature = await this.messagingSystem.call( - 'KeyringController:signPersonalMessage', - { - data: hexMessage, - from: account.address, - }, - ); + const executeMobileOptin = async (ts: number, sig: string) => { + try { + return await this.messagingSystem.call( + 'RewardsDataService:mobileOptin', + { + account: account.address, + timestamp: ts, + signature: sig as `0x${string}`, + referralCode, + }, + ); + } catch (error) { + // Check if it's an InvalidTimestampError and we haven't exceeded retry attempts + if ( + error instanceof InvalidTimestampError && + retryAttempt < MAX_RETRY_ATTEMPTS + ) { + retryAttempt++; + Logger.log('RewardsController: Retrying with server timestamp', { + originalTimestamp: ts, + newTimestamp: error.timestamp, + }); + // Use the timestamp from the error for retry + timestamp = error.timestamp; + signature = await this.#signRewardsMessage(account, timestamp); + return await executeMobileOptin(timestamp, signature); + } + throw error; + } + }; - Logger.log('RewardsController: Submitting optin with signature...'); - const optinResponse = await this.messagingSystem.call( - 'RewardsDataService:optin', - { - challengeId: challengeResponse.id, - signature, - referralCode, - }, - ); + const optinResponse = await executeMobileOptin(timestamp, signature); Logger.log( 'RewardsController: Optin successful, updating controller state...', diff --git a/app/core/Engine/controllers/rewards-controller/services/index.ts b/app/core/Engine/controllers/rewards-controller/services/index.ts index e0f2c4a32860..a534b36041cf 100644 --- a/app/core/Engine/controllers/rewards-controller/services/index.ts +++ b/app/core/Engine/controllers/rewards-controller/services/index.ts @@ -8,8 +8,7 @@ export type { RewardsDataServiceMessenger, RewardsDataServiceGetSeasonStatusAction, RewardsDataServiceGetReferralDetailsAction, - RewardsDataServiceGenerateChallengeAction, - RewardsDataServiceOptinAction, + RewardsDataServiceMobileOptinAction, RewardsDataServiceLogoutAction, RewardsDataServiceFetchGeoLocationAction, RewardsDataServiceValidateReferralCodeAction, diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index 85eb2bdbb5e1..ed70a495275b 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -12,6 +12,7 @@ import type { PointsBoostEnvelopeDto, ClaimRewardDto, MobileLoginDto, + MobileOptinDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import type { CaipAccountId } from '@metamask/utils'; @@ -88,17 +89,13 @@ describe('RewardsDataService', () => { expect.any(Function), ); expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( - 'RewardsDataService:optin', + 'RewardsDataService:mobileOptin', expect.any(Function), ); expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( 'RewardsDataService:logout', expect.any(Function), ); - expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( - 'RewardsDataService:generateChallenge', - expect.any(Function), - ); expect(mockMessenger.registerActionHandler).toHaveBeenCalledWith( 'RewardsDataService:getSeasonStatus', expect.any(Function), @@ -1119,12 +1116,20 @@ describe('RewardsDataService', () => { describe('optin', () => { const mockOptinRequest = { - challengeId: 'challenge-123', + account: '0x123', + timestamp: 1234567890, signature: '0xsignature123', referralCode: 'REF123', - }; + } as MobileOptinDto; - it('should successfully perform optin', async () => { + const mockSolanaOptinRequest = { + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature123', + referralCode: 'REF123', + } as MobileOptinDto; + + it('should successfully perform optin for EVM accounts', async () => { // Arrange const mockOptinResponse = { sessionId: 'session-456', @@ -1142,12 +1147,12 @@ describe('RewardsDataService', () => { mockFetch.mockResolvedValue(mockResponse); // Act - const result = await service.optin(mockOptinRequest); + const result = await service.mobileOptin(mockOptinRequest); // Assert expect(result).toEqual(mockOptinResponse); expect(mockFetch).toHaveBeenCalledWith( - 'https://api.rewards.test/auth/login', + 'https://api.rewards.test/auth/mobile-optin', expect.objectContaining({ method: 'POST', body: JSON.stringify(mockOptinRequest), @@ -1159,12 +1164,48 @@ describe('RewardsDataService', () => { ); }); + it('should successfully perform optin for Solana accounts', async () => { + // Arrange + const mockOptinResponse = { + sessionId: 'session-456', + subscription: { + id: 'sol-789', + referralCode: 'REF123', + accounts: [], + }, + }; + + const mockResponse = { + ok: true, + json: jest.fn().mockResolvedValue(mockOptinResponse), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + // Act + const result = await service.mobileOptin(mockSolanaOptinRequest); + + // Assert + expect(result).toEqual(mockOptinResponse); + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.rewards.test/auth/mobile-optin', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(mockSolanaOptinRequest), + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'rewards-client-id': 'mobile-7.50.1', + }), + }), + ); + }); + it('should handle optin without referral code', async () => { // Arrange const requestWithoutReferral = { - challengeId: 'challenge-123', + account: '0x123', + timestamp: 1234567890, signature: '0xsignature123', - }; + } as MobileOptinDto; const mockOptinResponse = { sessionId: 'session-456', @@ -1182,12 +1223,12 @@ describe('RewardsDataService', () => { mockFetch.mockResolvedValue(mockResponse); // Act - const result = await service.optin(requestWithoutReferral); + const result = await service.mobileOptin(requestWithoutReferral); // Assert expect(result).toEqual(mockOptinResponse); expect(mockFetch).toHaveBeenCalledWith( - 'https://api.rewards.test/auth/login', + 'https://api.rewards.test/auth/mobile-optin', expect.objectContaining({ method: 'POST', body: JSON.stringify(requestWithoutReferral), @@ -1204,7 +1245,7 @@ describe('RewardsDataService', () => { mockFetch.mockResolvedValue(mockResponse); // Act & Assert - await expect(service.optin(mockOptinRequest)).rejects.toThrow( + await expect(service.mobileOptin(mockOptinRequest)).rejects.toThrow( 'Optin failed: 400', ); }); @@ -1214,7 +1255,7 @@ describe('RewardsDataService', () => { mockFetch.mockRejectedValue(new Error('Network error')); // Act & Assert - await expect(service.optin(mockOptinRequest)).rejects.toThrow( + await expect(service.mobileOptin(mockOptinRequest)).rejects.toThrow( 'Network error', ); }); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 59917e7a386e..542557bd7cac 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -7,10 +7,7 @@ import type { EstimatedPointsDto, GetPerpsDiscountDto, PerpsDiscountData, - LoginDto, SeasonStatusDto, - GenerateChallengeDto, - ChallengeResponseDto, SubscriptionReferralDetailsDto, PaginatedPointsEventsDto, GetPointsEventsDto, @@ -22,6 +19,7 @@ import type { PointsBoostEnvelopeDto, RewardDto, ClaimRewardDto, + MobileOptinDto, } from '../types'; import { getSubscriptionToken } from '../utils/multi-subscription-token-vault'; import Logger from '../../../../../util/Logger'; @@ -73,9 +71,9 @@ export interface RewardsDataServiceGetPerpsDiscountAction { type: `${typeof SERVICE_NAME}:getPerpsDiscount`; handler: RewardsDataService['getPerpsDiscount']; } -export interface RewardsDataServiceOptinAction { - type: `${typeof SERVICE_NAME}:optin`; - handler: RewardsDataService['optin']; +export interface RewardsDataServiceMobileOptinAction { + type: `${typeof SERVICE_NAME}:mobileOptin`; + handler: RewardsDataService['mobileOptin']; } export interface RewardsDataServiceLogoutAction { @@ -83,11 +81,6 @@ export interface RewardsDataServiceLogoutAction { handler: RewardsDataService['logout']; } -export interface RewardsDataServiceGenerateChallengeAction { - type: `${typeof SERVICE_NAME}:generateChallenge`; - handler: RewardsDataService['generateChallenge']; -} - export interface RewardsDataServiceGetSeasonStatusAction { type: `${typeof SERVICE_NAME}:getSeasonStatus`; handler: RewardsDataService['getSeasonStatus']; @@ -145,9 +138,8 @@ export type RewardsDataServiceActions = | RewardsDataServiceGetPerpsDiscountAction | RewardsDataServiceGetSeasonStatusAction | RewardsDataServiceGetReferralDetailsAction - | RewardsDataServiceOptinAction + | RewardsDataServiceMobileOptinAction | RewardsDataServiceLogoutAction - | RewardsDataServiceGenerateChallengeAction | RewardsDataServiceFetchGeoLocationAction | RewardsDataServiceValidateReferralCodeAction | RewardsDataServiceMobileJoinAction @@ -213,17 +205,13 @@ export class RewardsDataService { this.getPerpsDiscount.bind(this), ); this.#messenger.registerActionHandler( - `${SERVICE_NAME}:optin`, - this.optin.bind(this), + `${SERVICE_NAME}:mobileOptin`, + this.mobileOptin.bind(this), ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:logout`, this.logout.bind(this), ); - this.#messenger.registerActionHandler( - `${SERVICE_NAME}:generateChallenge`, - this.generateChallenge.bind(this), - ); this.#messenger.registerActionHandler( `${SERVICE_NAME}:getSeasonStatus`, this.getSeasonStatus.bind(this), @@ -483,31 +471,12 @@ export class RewardsDataService { } /** - * Generate a challenge for authentication. - * @param body - The challenge request body containing the address. - * @returns The challenge response DTO. - */ - async generateChallenge( - body: GenerateChallengeDto, - ): Promise { - const response = await this.makeRequest('/auth/challenge/generate', { - method: 'POST', - body: JSON.stringify(body), - }); - if (!response.ok) { - throw new Error(`Generate challenge failed: ${response.status}`); - } - - return (await response.json()) as ChallengeResponseDto; - } - - /** - * Perform optin (login) via challenge and signature. - * @param body - The login request body containing challengeId, signature, and optional referralCode. + * Perform optin via signature for the current account. + * @param body - The login request body containing account, timestamp, signature and referral code. * @returns The login response DTO. */ - async optin(body: LoginDto): Promise { - const response = await this.makeRequest('/auth/login', { + async mobileOptin(body: MobileOptinDto): Promise { + const response = await this.makeRequest('/auth/mobile-optin', { method: 'POST', body: JSON.stringify(body), }); diff --git a/app/core/Engine/controllers/rewards-controller/types.ts b/app/core/Engine/controllers/rewards-controller/types.ts index f17eb1f4acbf..0836806c117d 100644 --- a/app/core/Engine/controllers/rewards-controller/types.ts +++ b/app/core/Engine/controllers/rewards-controller/types.ts @@ -17,27 +17,27 @@ export type SubscriptionDto = { }[]; }; -export interface GenerateChallengeDto { - address: string; -} +export interface MobileLoginDto { + /** + * The account of the user + * @example '0x... or solana address.' + */ + account: string; -export interface ChallengeResponseDto { - id: string; - message: string; - domain?: string; - address?: string; - issuedAt?: string; - expirationTime?: string; - nonce?: string; -} + /** + * The timestamp (epoch seconds) used in the signature. + * @example 1 + */ + timestamp: number; -export interface LoginDto { - challengeId: string; - signature: string; - referralCode?: string; + /** + * The signature of the login (hex encoded) + * @example '0x...' + */ + signature: `0x${string}`; } -export interface MobileLoginDto { +export interface MobileOptinDto { /** * The account of the user * @example '0x... or solana address.' @@ -55,6 +55,12 @@ export interface MobileLoginDto { * @example '0x...' */ signature: `0x${string}`; + + /** + * The referral code of the user + * @example '123456' + */ + referralCode?: string; } export interface EstimateAssetDto { diff --git a/app/core/Engine/messengers/rewards-controller-messenger/index.ts b/app/core/Engine/messengers/rewards-controller-messenger/index.ts index 2d3ae64324f0..fa80c57d7f06 100644 --- a/app/core/Engine/messengers/rewards-controller-messenger/index.ts +++ b/app/core/Engine/messengers/rewards-controller-messenger/index.ts @@ -10,8 +10,7 @@ import { RewardsDataServiceGetPerpsDiscountAction, RewardsDataServiceGetSeasonStatusAction, RewardsDataServiceGetReferralDetailsAction, - RewardsDataServiceGenerateChallengeAction, - RewardsDataServiceOptinAction, + RewardsDataServiceMobileOptinAction, RewardsDataServiceLogoutAction, RewardsDataServiceFetchGeoLocationAction, RewardsDataServiceValidateReferralCodeAction, @@ -48,8 +47,7 @@ type AllowedActions = | RewardsDataServiceGetPerpsDiscountAction | RewardsDataServiceGetSeasonStatusAction | RewardsDataServiceGetReferralDetailsAction - | RewardsDataServiceGenerateChallengeAction - | RewardsDataServiceOptinAction + | RewardsDataServiceMobileOptinAction | RewardsDataServiceLogoutAction | RewardsDataServiceFetchGeoLocationAction | RewardsDataServiceValidateReferralCodeAction @@ -91,8 +89,7 @@ export function getRewardsControllerMessenger( 'RewardsDataService:getPerpsDiscount', 'RewardsDataService:getSeasonStatus', 'RewardsDataService:getReferralDetails', - 'RewardsDataService:generateChallenge', - 'RewardsDataService:optin', + 'RewardsDataService:mobileOptin', 'RewardsDataService:logout', 'RewardsDataService:fetchGeoLocation', 'RewardsDataService:validateReferralCode', From df32d2bc6e1c24e8a7c5fea1d84adee821eede8f Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 29 Sep 2025 17:05:53 -0400 Subject: [PATCH 4/7] Fix lint --- .../controllers/rewards-controller/RewardsController.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index 817550a3ffa5..b7861a20ccf5 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -1340,7 +1340,10 @@ export class RewardsController extends BaseController< let retryAttempt = 0; const MAX_RETRY_ATTEMPTS = 1; - const executeMobileOptin = async (ts: number, sig: string) => { + const executeMobileOptin = async ( + ts: number, + sig: string, + ): Promise => { try { return await this.messagingSystem.call( 'RewardsDataService:mobileOptin', From 3c3a3f3bf65696c44cfa349b3b8ef92e6e5898d8 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 29 Sep 2025 17:25:04 -0400 Subject: [PATCH 5/7] Throw InvalidTimestampError --- .../RewardsController.test.ts | 125 +++++++++--------- .../services/rewards-data-service.test.ts | 27 +++- .../services/rewards-data-service.ts | 10 ++ 3 files changed, 98 insertions(+), 64 deletions(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index ad7a6c03ad5c..0803d927b258 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3121,30 +3121,70 @@ describe('RewardsController', () => { sessionId: 'test-session-id', }; - // Mock the messaging system calls - mockMessenger.call.mockImplementation((method, ..._args): any => { - const params = _args[0] as any; - if (method === 'RewardsDataService:mobileOptin') { - // Don't verify signature here as it might not be set yet - expect(params.account).toBe(mockSolanaInternalAccount.address); - return Promise.resolve(mockOptinResponse); - } - return Promise.resolve(); - }); + // Save original mock implementations to restore later + const originalIsSolanaAddress = + mockIsSolanaAddress.getMockImplementation(); + const originalIsNonEvmAddress = + mockIsNonEvmAddress.getMockImplementation(); + const originalSignSolanaRewardsMessage = + mockSignSolanaRewardsMessage.getMockImplementation(); - // Act - const result = await controller.optIn(mockSolanaInternalAccount); + try { + // Mock isSolanaAddress to return true for the Solana account + mockIsSolanaAddress.mockReturnValue(true); + mockIsNonEvmAddress.mockReturnValue(true); + + // Mock signSolanaRewardsMessage + mockSignSolanaRewardsMessage.mockResolvedValue({ + signedMessage: 'base64-encoded-message', + signature: 'solana-signature', + signatureType: 'ed25519', + }); - // Assert - expect(result).toBe(mockSubscriptionId); - // We're not actually calling SolanaWalletSnapController:signMessage in the implementation - // but rather KeyringController:signPersonalMessage, so adjust the assertion - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signPersonalMessage', - expect.objectContaining({ - from: mockSolanaInternalAccount.address, - }), - ); + // Mock base58.decode to return a Buffer + const originalBase58Decode = ( + base58.decode as jest.Mock + ).getMockImplementation(); + (base58.decode as jest.Mock).mockReturnValue( + Buffer.from('01020304', 'hex'), + ); + + // Mock the messaging system calls + const originalMessengerCall = + mockMessenger.call.getMockImplementation(); + mockMessenger.call.mockImplementation((method, ..._args): any => { + const params = _args[0] as any; + if (method === 'RewardsDataService:mobileOptin') { + // Don't verify signature here as it might not be set yet + expect(params.account).toBe(mockSolanaInternalAccount.address); + return Promise.resolve(mockOptinResponse); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.optIn(mockSolanaInternalAccount); + + // Assert + expect(result).toBe(mockSubscriptionId); + // Verify that signSolanaRewardsMessage was called instead of KeyringController:signPersonalMessage + expect(mockSignSolanaRewardsMessage).toHaveBeenCalled(); + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.any(Object), + ); + + // Restore original mocks + mockMessenger.call.mockImplementation(originalMessengerCall); + (base58.decode as jest.Mock).mockImplementation(originalBase58Decode); + } finally { + // Always restore original mocks even if test fails + mockIsSolanaAddress.mockImplementation(originalIsSolanaAddress); + mockIsNonEvmAddress.mockImplementation(originalIsNonEvmAddress); + mockSignSolanaRewardsMessage.mockImplementation( + originalSignSolanaRewardsMessage, + ); + } expect(mockMessenger.call).toHaveBeenCalledWith( 'RewardsDataService:mobileOptin', expect.objectContaining({ @@ -3185,47 +3225,6 @@ describe('RewardsController', () => { ); }); - it('should use Buffer fallback when toHex fails during hex message conversion', async () => { - // Arrange - const mockOptinResponse = { - sessionId: 'session-456', - subscription: { - id: 'sub-789', - referralCode: 'REF123', - accounts: [], - }, - }; - - // Mock toHex to throw an error, triggering the Buffer fallback - mockToHex.mockImplementation(() => { - throw new Error('toHex encoding error'); - }); - - // Mock the challenge response - mockMessenger.call.mockImplementation((method, ..._args): any => { - if (method === 'KeyringController:signPersonalMessage') { - // We don't need to verify the exact data here, just that it was called - return Promise.resolve('0xsignature123'); - } else if (method === 'RewardsDataService:mobileOptin') { - return Promise.resolve(mockOptinResponse); - } - return Promise.resolve(); - }); - - // Act - await controller.optIn(mockEvmInternalAccount); - - // Assert - we know toHex was called and threw an error, so we don't need to verify the exact parameters - - // Just verify the call was made with the right method and address - expect(mockMessenger.call).toHaveBeenCalledWith( - 'KeyringController:signPersonalMessage', - expect.objectContaining({ - from: mockEvmInternalAccount.address, - }), - ); - }); - it('should store subscription token when optin response has subscription id and session id', async () => { // Arrange const mockOptinResponse = { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index ed70a495275b..e88a1cfb71fd 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -1241,7 +1241,8 @@ describe('RewardsDataService', () => { const mockResponse = { ok: false, status: 400, - } as Response; + json: jest.fn().mockResolvedValue({ message: 'Bad request' }), + } as unknown as Response; mockFetch.mockResolvedValue(mockResponse); // Act & Assert @@ -1250,6 +1251,30 @@ describe('RewardsDataService', () => { ); }); + it('should throw InvalidTimestampError when server returns invalid timestamp error during mobileOptin', async () => { + // Arrange + const mockErrorResponse = { + ok: false, + status: 400, + json: jest.fn().mockResolvedValue({ + message: 'Invalid timestamp', + serverTimestamp: 1234567000000, // Server timestamp in milliseconds + }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockErrorResponse); + + // Act & Assert + try { + await service.mobileOptin(mockOptinRequest); + fail('Expected InvalidTimestampError to be thrown'); + } catch (error) { + expect((error as InvalidTimestampError).name).toBe( + 'InvalidTimestampError', + ); + expect((error as InvalidTimestampError).timestamp).toBe(1234567000); // Server timestamp in seconds + } + }); + it('should handle network errors during optin', async () => { // Arrange mockFetch.mockRejectedValue(new Error('Network error')); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 542557bd7cac..62b85ee9340b 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -482,6 +482,16 @@ export class RewardsDataService { }); if (!response.ok) { + const errorData = await response.json(); + Logger.log('RewardsDataService: mobileOptin errorData', errorData); + + if (errorData?.message?.includes('Invalid timestamp')) { + // Retry signing with a new timestamp + throw new InvalidTimestampError( + 'Invalid timestamp. Please try again with a new timestamp.', + Math.floor(Number(errorData.serverTimestamp) / 1000), + ); + } throw new Error(`Optin failed: ${response.status}`); } From 922af5588466592d052c4fcc6e13c7218791fd38 Mon Sep 17 00:00:00 2001 From: sophieqgu Date: Mon, 29 Sep 2025 18:29:50 -0400 Subject: [PATCH 6/7] Add test coverage --- .../RewardsController.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 0803d927b258..81f4460467e0 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -3225,6 +3225,60 @@ describe('RewardsController', () => { ); }); + it('should handle InvalidTimestampError and retry with server timestamp', async () => { + // Arrange + const mockSubscriptionId = 'test-subscription-id'; + const mockOptinResponse = { + subscription: { id: mockSubscriptionId }, + sessionId: 'test-session-id', + }; + + // Import the actual InvalidTimestampError + const { InvalidTimestampError } = jest.requireActual( + './services/rewards-data-service', + ); + const mockError = new InvalidTimestampError('Invalid timestamp', 12345); + + // Mock the messaging system calls + let callCount = 0; + + mockMessenger.call.mockImplementation((method, ..._args): any => { + if (method === 'RewardsDataService:mobileOptin') { + callCount++; + const { account, signature, timestamp } = _args[0] as any; + + // First call fails with timestamp error, second call succeeds + if (callCount === 1) { + expect(account).toBe(mockEvmInternalAccount.address); + expect(signature).toBeDefined(); + expect(timestamp).toBeDefined(); + return Promise.reject(mockError); + } + // Verify the second call uses the server timestamp + expect(account).toBe(mockEvmInternalAccount.address); + expect(signature).toBeDefined(); + expect(timestamp).toBe(12345); + return Promise.resolve(mockOptinResponse); + } else if (method === 'KeyringController:signPersonalMessage') { + return Promise.resolve('0xsignature'); + } + return Promise.resolve(); + }); + + // Act + const result = await controller.optIn(mockEvmInternalAccount); + + // Assert + expect(result).toBe(mockSubscriptionId); + expect(mockMessenger.call).toHaveBeenCalledWith( + 'KeyringController:signPersonalMessage', + expect.objectContaining({ + from: mockEvmInternalAccount.address, + }), + ); + expect(callCount).toBe(2); // Verify retry happened + }); + it('should store subscription token when optin response has subscription id and session id', async () => { // Arrange const mockOptinResponse = { From 0aa5929bbed2d08261eeec813a490334fff1be55 Mon Sep 17 00:00:00 2001 From: Rik Van Gulck Date: Tue, 30 Sep 2025 11:40:21 +0200 Subject: [PATCH 7/7] lint fix --- .../controllers/rewards-controller/RewardsController.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 81f4460467e0..912ef5b018a3 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -1934,7 +1934,7 @@ describe('RewardsController', () => { typeof base58.decode >; mockBase58Decode.mockReturnValue( - Buffer.from('decodedSolanaSignature', 'utf8'), + Buffer.from('decodedSolanaSignature', 'utf8') as unknown as Uint8Array, ); mockToHex.mockReturnValue('0xdecodedSolanaSignature');