diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 86c93c554a7c..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'); @@ -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,114 +3056,231 @@ 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')); + // Save original mock implementations to restore later + const originalIsSolanaAddress = + mockIsSolanaAddress.getMockImplementation(); + const originalIsNonEvmAddress = + mockIsNonEvmAddress.getMockImplementation(); + const originalSignSolanaRewardsMessage = + mockSignSolanaRewardsMessage.getMockImplementation(); + + 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', + }); + + // 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({ + 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 () => { + it('should handle InvalidTimestampError and retry with server timestamp', async () => { // Arrange - const mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge with special chars: éñü', - }; - const mockSignature = '0xsignature123'; + const mockSubscriptionId = 'test-subscription-id'; const mockOptinResponse = { - sessionId: 'session-456', - subscription: { - id: 'sub-789', - referralCode: 'REF123', - accounts: [], - }, + subscription: { id: mockSubscriptionId }, + sessionId: 'test-session-id', }; - // Mock toHex to throw an error, triggering the Buffer fallback - mockToHex.mockImplementation(() => { - throw new Error('toHex encoding error'); - }); + // Import the actual InvalidTimestampError + const { InvalidTimestampError } = jest.requireActual( + './services/rewards-data-service', + ); + const mockError = new InvalidTimestampError('Invalid timestamp', 12345); - mockMessenger.call - .mockResolvedValueOnce(mockChallengeResponse) // generateChallenge - .mockResolvedValueOnce(mockSignature) // signPersonalMessage - .mockResolvedValueOnce(mockOptinResponse); // optin + // 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 - await controller.optIn(mockInternalAccount); + const result = 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, + expect(result).toBe(mockSubscriptionId); + expect(mockMessenger.call).toHaveBeenCalledWith( 'KeyringController:signPersonalMessage', - { - data: expectedBufferHex, - from: mockInternalAccount.address, - }, + 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 mockChallengeResponse = { - id: 'challenge-123', - message: 'test challenge message', - }; - const mockSignature = '0xsignature123'; const mockOptinResponse = { sessionId: 'session-456', subscription: { @@ -3159,15 +3290,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 +3317,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 +3326,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 +3359,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 +3368,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 +3387,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 +3396,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..b7861a20ccf5 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,47 @@ 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, + ): Promise => { + 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..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 @@ -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; + + const mockSolanaOptinRequest = { + account: '0x123', + timestamp: 1234567890, + signature: '0xsignature123', + referralCode: 'REF123', + } as MobileOptinDto; - it('should successfully perform optin', async () => { + 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), @@ -1200,21 +1241,46 @@ 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 - await expect(service.optin(mockOptinRequest)).rejects.toThrow( + await expect(service.mobileOptin(mockOptinRequest)).rejects.toThrow( 'Optin failed: 400', ); }); + 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')); // 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..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 @@ -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,36 +471,27 @@ 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), }); 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}`); } 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',