From 72029aaa5597827d3cabab55590b2cdcb79191df Mon Sep 17 00:00:00 2001 From: abretonc7s <107169956+abretonc7s@users.noreply.github.com> Date: Wed, 4 Mar 2026 01:39:43 +0800 Subject: [PATCH 1/6] refactor(perps): reduce external dependencies via messenger and DI (#26394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Refactors PerpsController to reduce external npm dependencies and improve portability for publishing to `MetaMask/core`. **Key changes:** - **Move cross-controller types to `types/index.ts`**: `AllowedActions`, `AllowedEvents`, and the new `PerpsControllerMessengerBase` type are defined in the shared types file instead of `PerpsController.ts`, breaking the circular import between services and the controller. - **Services receive messenger directly**: All 6 services (`AccountService`, `DataLakeService`, `DepositService`, `HyperLiquidWalletService`, `RewardsIntegrationService`, `HyperLiquidProvider`) now accept a `PerpsControllerMessengerBase` and call cross-controller actions themselves, instead of receiving the full `PerpsControllerMessenger`. - **Inline external utilities**: `isEvmAccountType` (from `@metamask/keyring-api`) and `formatChainIdToCaip` (from `@metamask/bridge-controller`) are replaced with local implementations to remove those npm dependencies. - **Simplify `getSelectedEvmAccount`**: Now takes an `accounts[]` array directly instead of a messenger, so callers explicitly fetch accounts via `messenger.call('AccountTreeController:getAccountsFromSelectedAccountGroup')`. - **Add local types**: `PerpsTypedMessageParams`, `PerpsTransactionParams`, `PerpsAddTransactionOptions`, `PerpsInternalAccount`, `PerpsRemoteFeatureFlagState` replace imported types from external controller packages. - **Rename rewards DI field**: `rewards.getFeeDiscount` → `rewards.getPerpsDiscountForAccount` to match the actual `RewardsController` method name. - **Clean up messenger delegate**: Removed unused transaction events (`transactionSubmitted`, `transactionConfirmed`, `transactionFailed`) from the delegate list and reordered actions to match the type definition. - **Simplify `mobileInfrastructure`**: Adapter only wires up `rewards` DI (the only field that can't go through the messenger since `RewardsController` is not in Core yet). All other controller access goes through the messenger. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** \`\`\`gherkin Feature: Perps trading after controller refactor Scenario: Trading interface loads correctly Given the app is connected to a supported network When user navigates to the Perps section Then the trading interface loads with market data And account balances and positions are displayed Scenario: Deposit flow works end-to-end Given the user has USDC on the connected chain When user initiates a deposit Then the transaction confirmation screen appears And deposit tracking state is updated after submission Scenario: Signing works for trade execution Given the user has an unlocked keyring When a trade requires a typed signature Then the signature is obtained and the trade executes \`\`\` ## **Screenshots/Recordings** ### **Before** N/A — internal refactor, no UI changes ### **After** N/A — internal refactor, no UI changes ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .../UI/Perps/__mocks__/serviceMocks.ts | 44 +- .../adapters/mobileInfrastructure.test.ts | 19 +- .../UI/Perps/adapters/mobileInfrastructure.ts | 19 +- .../UI/Perps/utils/accountUtils.test.ts | 48 +-- .../UI/Perps/utils/rewardsUtils.test.ts | 22 +- app/controllers/perps/PerpsController.test.ts | 404 +++++++++++------- app/controllers/perps/PerpsController.ts | 132 +++--- .../providers/HyperLiquidProvider.test.ts | 1 + .../perps/providers/HyperLiquidProvider.ts | 15 +- .../perps/services/AccountService.test.ts | 11 +- .../perps/services/AccountService.ts | 14 +- .../perps/services/DataLakeService.test.ts | 41 +- .../perps/services/DataLakeService.ts | 19 +- .../perps/services/DepositService.test.ts | 5 +- .../perps/services/DepositService.ts | 25 +- .../FeatureFlagConfigurationService.ts | 14 +- .../services/HyperLiquidWalletService.test.ts | 103 +---- .../services/HyperLiquidWalletService.ts | 46 +- .../RewardsIntegrationService.test.ts | 177 +++----- .../services/RewardsIntegrationService.ts | 25 +- .../perps/services/ServiceContext.ts | 12 +- app/controllers/perps/types/index.ts | 76 +++- app/controllers/perps/types/messenger.ts | 57 +++ app/controllers/perps/utils/accountUtils.ts | 23 +- .../perps/utils/rewardsUtils.test.ts | 102 +++++ app/controllers/perps/utils/rewardsUtils.ts | 20 +- .../perps-controller-messenger/index.ts | 19 +- scripts/perps/validate-core-sync.sh | 77 +++- 28 files changed, 901 insertions(+), 669 deletions(-) create mode 100644 app/controllers/perps/types/messenger.ts create mode 100644 app/controllers/perps/utils/rewardsUtils.test.ts diff --git a/app/components/UI/Perps/__mocks__/serviceMocks.ts b/app/components/UI/Perps/__mocks__/serviceMocks.ts index 1117c9af213..093f484227e 100644 --- a/app/components/UI/Perps/__mocks__/serviceMocks.ts +++ b/app/components/UI/Perps/__mocks__/serviceMocks.ts @@ -11,6 +11,23 @@ import { type PerpsPlatformDependencies, } from '@metamask/perps-controller'; +/** + * Create a mock EVM account (KeyringAccount) + */ +export const createMockEvmAccount = () => ({ + id: '00000000-0000-0000-0000-000000000000', + address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, + type: 'eip155:eoa' as const, + options: {}, + scopes: ['eip155:1'], + methods: ['eth_signTransaction', 'eth_sign'], + metadata: { + name: 'Test Account', + importTime: Date.now(), + keyring: { type: 'HD Key Tree' }, + }, +}); + /** * Create a mock PerpsPlatformDependencies instance. * Returns a type-safe mock with jest.Mock functions for all methods. @@ -53,11 +70,6 @@ export const createMockInfrastructure = clearAllChannels: jest.fn(), }, - // === Rewards (no standard messenger action in core) === - rewards: { - getFeeDiscount: jest.fn().mockResolvedValue(0), - }, - // === Feature Flags (platform-specific version gating) === featureFlags: { validateVersionGated: jest.fn().mockReturnValue(undefined), @@ -76,6 +88,11 @@ export const createMockInfrastructure = invalidate: jest.fn(), invalidateAll: jest.fn(), }, + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(0), + }, }) as unknown as jest.Mocked; /** @@ -161,23 +178,6 @@ export const createMockServiceContext = ( ...overrides, }); -/** - * Create a mock EVM account (KeyringAccount) - */ -export const createMockEvmAccount = () => ({ - id: '00000000-0000-0000-0000-000000000000', - address: '0x1234567890abcdef1234567890abcdef12345678' as `0x${string}`, - type: 'eip155:eoa' as const, - options: {}, - scopes: ['eip155:1'], - methods: ['eth_signTransaction', 'eth_sign'], - metadata: { - name: 'Test Account', - importTime: Date.now(), - keyring: { type: 'HD Key Tree' }, - }, -}); - /** * Create a mock PerpsControllerMessenger for testing inter-controller communication. * The messenger.call() method should be configured in each test to return appropriate values. diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts index baad6cc4f59..eb70ccec472 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.test.ts @@ -3,6 +3,7 @@ import { AnalyticsEventBuilder } from '../../../../util/analytics/AnalyticsEvent import { analytics } from '../../../../util/analytics/analytics'; import type { PerpsAnalyticsEvent } from '@metamask/perps-controller'; import { createMobileInfrastructure } from './mobileInfrastructure'; +import Engine from '../../../../core/Engine'; jest.mock('../../../../util/analytics/analytics', () => ({ analytics: { @@ -55,7 +56,7 @@ jest.mock('../providers/PerpsStreamManager', () => ({ jest.mock('../../../../core/Engine', () => ({ context: { RewardsController: { - getPerpsDiscountForAccount: jest.fn(), + getPerpsDiscountForAccount: jest.fn().mockResolvedValue(5), }, }, })); @@ -159,4 +160,20 @@ describe('createMobileInfrastructure', () => { }); }); }); + + describe('rewards', () => { + it('delegates getPerpsDiscountForAccount to RewardsController', async () => { + const infra = createMobileInfrastructure(); + const caipAccountId = + 'eip155:42161:0x1234' as `${string}:${string}:${string}`; + + const result = + await infra.rewards.getPerpsDiscountForAccount(caipAccountId); + + expect( + Engine.context.RewardsController.getPerpsDiscountForAccount, + ).toHaveBeenCalledWith(caipAccountId); + expect(result).toBe(5); + }); + }); }); diff --git a/app/components/UI/Perps/adapters/mobileInfrastructure.ts b/app/components/UI/Perps/adapters/mobileInfrastructure.ts index 91e873d2612..2e51bee9e55 100644 --- a/app/components/UI/Perps/adapters/mobileInfrastructure.ts +++ b/app/components/UI/Perps/adapters/mobileInfrastructure.ts @@ -216,14 +216,6 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Platform Services === streamManager: createStreamManagerAdapter(), - // === Rewards === - rewards: { - getFeeDiscount: (caipAccountId: `${string}:${string}:${string}`) => - Engine.context.RewardsController.getPerpsDiscountForAccount( - caipAccountId, - ), - }, - // === Feature Flags === featureFlags: { validateVersionGated(flag: VersionGatedFeatureFlag): boolean | undefined { @@ -236,6 +228,17 @@ export function createMobileInfrastructure(): PerpsPlatformDependencies { // === Cache Invalidation === cacheInvalidator: createCacheInvalidatorAdapter(), + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + getPerpsDiscountForAccount( + caipAccountId: `${string}:${string}:${string}`, + ) { + return Engine.context.RewardsController.getPerpsDiscountForAccount( + caipAccountId, + ); + }, + }, }; } diff --git a/app/components/UI/Perps/utils/accountUtils.test.ts b/app/components/UI/Perps/utils/accountUtils.test.ts index 060db48acc8..f3bf3b73c6e 100644 --- a/app/components/UI/Perps/utils/accountUtils.test.ts +++ b/app/components/UI/Perps/utils/accountUtils.test.ts @@ -8,7 +8,6 @@ import { getEvmAccountFromAccountGroup, getSelectedEvmAccount, calculateWeightedReturnOnEquity, - PerpsControllerMessenger, } from '@metamask/perps-controller'; describe('accountUtils', () => { @@ -242,7 +241,7 @@ describe('accountUtils', () => { }); describe('getSelectedEvmAccount', () => { - it('returns EVM account when messenger returns accounts with EVM', () => { + it('returns EVM account when accounts array contains EVM account', () => { const mockAccounts = [ { address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', @@ -259,27 +258,14 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); - - expect(mockMessenger.call).toHaveBeenCalledWith( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); + const result = getSelectedEvmAccount(mockAccounts); + expect(result).toEqual({ address: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcdef', }); }); - it('returns undefined when no EVM account in selected group', () => { + it('returns undefined when no EVM account in accounts array', () => { const mockAccounts = [ { address: '0x1234567890123456789012345678901234567890', @@ -296,33 +282,13 @@ describe('accountUtils', () => { }, ] as unknown as InternalAccount[]; - const mockMessenger = { - call: jest.fn().mockReturnValue(mockAccounts) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + const result = getSelectedEvmAccount(mockAccounts); expect(result).toBeUndefined(); }); - it('returns undefined when messenger returns empty accounts', () => { - const mockMessenger = { - call: jest.fn().mockReturnValue([]) as jest.MockedFunction< - ( - action: 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ) => InternalAccount[] - >, - }; - - const result = getSelectedEvmAccount( - mockMessenger as unknown as PerpsControllerMessenger, - ); + it('returns undefined when accounts array is empty', () => { + const result = getSelectedEvmAccount([]); expect(result).toBeUndefined(); }); diff --git a/app/components/UI/Perps/utils/rewardsUtils.test.ts b/app/components/UI/Perps/utils/rewardsUtils.test.ts index 0697848fca3..d490a5b46b6 100644 --- a/app/components/UI/Perps/utils/rewardsUtils.test.ts +++ b/app/components/UI/Perps/utils/rewardsUtils.test.ts @@ -8,11 +8,9 @@ import { handleRewardsError, } from '@metamask/perps-controller'; import { toCaipAccountId, parseCaipChainId } from '@metamask/utils'; -import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; jest.mock('@metamask/utils'); -jest.mock('@metamask/bridge-controller'); jest.mock('@metamask/controller-utils'); const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< @@ -21,9 +19,6 @@ const mockToCaipAccountId = toCaipAccountId as jest.MockedFunction< const mockParseCaipChainId = parseCaipChainId as jest.MockedFunction< typeof parseCaipChainId >; -const mockFormatChainIdToCaip = formatChainIdToCaip as jest.MockedFunction< - typeof formatChainIdToCaip ->; const mockToChecksumHexAddress = toChecksumHexAddress as jest.MockedFunction< typeof toChecksumHexAddress >; @@ -41,7 +36,6 @@ describe('rewardsUtils', () => { 'eip155:42161:0x1234567890123456789012345678901234567890'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(mockCaipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '42161', @@ -57,7 +51,6 @@ describe('rewardsUtils', () => { // Assert expect(result).toBe(mockCaipAccountId); - expect(mockFormatChainIdToCaip).toHaveBeenCalledWith(mockChainId); expect(mockParseCaipChainId).toHaveBeenCalledWith(mockCaipChainId); expect(mockToCaipAccountId).toHaveBeenCalledWith( 'eip155', @@ -66,13 +59,7 @@ describe('rewardsUtils', () => { ); }); - it('returns null when formatChainIdToCaip throws', () => { - // Arrange - const error = new Error('Invalid chain ID format'); - mockFormatChainIdToCaip.mockImplementation(() => { - throw error; - }); - + it('returns null when chain ID is invalid (NaN)', () => { // Act const result = formatAccountToCaipAccountId(mockAddress, 'invalid'); @@ -127,10 +114,8 @@ describe('rewardsUtils', () => { const checksummedAddress = '0x316BDE155acd07609872a56Bc32CcfB0B13201fA'; const mixedCaseAddress = '0x316BdE155AcD07609872a56bC32CcFb0b13201Fa'; const chainId = '1'; - const caipChainId = 'eip155:1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue(caipChainId); mockParseCaipChainId.mockReturnValue({ namespace: 'eip155', reference: '1', @@ -238,9 +223,8 @@ describe('rewardsUtils', () => { const chainId = '1'; beforeEach(() => { - mockFormatChainIdToCaip.mockReturnValue( - 'bip122:000000000019d6689c085ae165831e93', - ); + // parseCaipChainId receives 'eip155:1' from inlined formatChainIdToCaip, + // but we mock it to return a non-eip155 namespace to test the branch mockParseCaipChainId.mockReturnValue({ namespace: 'bip122', reference: '000000000019d6689c085ae165831e93', diff --git a/app/controllers/perps/PerpsController.test.ts b/app/controllers/perps/PerpsController.test.ts index 3875eefac18..49879d9df4d 100644 --- a/app/controllers/perps/PerpsController.test.ts +++ b/app/controllers/perps/PerpsController.test.ts @@ -516,8 +516,8 @@ describe('PerpsController', () => { }); it('reads current RemoteFeatureFlagController state during construction', () => { - // Given: A mock messenger that tracks calls - const mockCall = jest.fn().mockImplementation((action: string) => { + // Given: A messenger that returns remote feature flags state + const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -529,24 +529,25 @@ describe('PerpsController', () => { } return undefined; }); + const testMessenger = createMockMessenger({ call: testMockCall }); // When: Controller is constructed const testController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: testMessenger, state: getDefaultPerpsControllerState(), infrastructure: createMockInfrastructure(), }); - // Then: Should have called to get RemoteFeatureFlagController state + // Then: Should have called to get RemoteFeatureFlagController state via messenger expect(testController).toBeDefined(); - expect(mockCall).toHaveBeenCalledWith( + expect(testMockCall).toHaveBeenCalledWith( 'RemoteFeatureFlagController:getState', ); }); it('applies remote blocked regions when available during construction', () => { - // Given: Remote feature flags with blocked regions - const mockCall = jest.fn().mockImplementation((action: string) => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -561,7 +562,7 @@ describe('PerpsController', () => { // When: Controller is constructed const testController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), infrastructure: createMockInfrastructure(), clientConfig: { @@ -604,8 +605,8 @@ describe('PerpsController', () => { }); it('never downgrade from remote to fallback regions', () => { - // Given: Remote feature flags with blocked regions - const mockCall = jest.fn().mockImplementation((action: string) => { + // Given: Messenger that returns remote feature flags with blocked regions + const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -620,7 +621,7 @@ describe('PerpsController', () => { // When: Controller is constructed with both remote and fallback const testController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), infrastructure: createMockInfrastructure(), clientConfig: { @@ -643,16 +644,16 @@ describe('PerpsController', () => { }); it('continues initialization when RemoteFeatureFlagController state call throws error', () => { - const mockCall = jest.fn().mockImplementation((action: string) => { + const testInfrastructure = createMockInfrastructure(); + const testMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { throw new Error('RemoteFeatureFlagController not ready'); } return undefined; }); - const testInfrastructure = createMockInfrastructure(); const testController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: createMockMessenger({ call: testMockCall }), state: getDefaultPerpsControllerState(), infrastructure: testInfrastructure, clientConfig: { @@ -2388,9 +2389,9 @@ describe('PerpsController', () => { const mockTransactionMeta = { id: 'tx-meta-123' }; const mockTxHash = '0xhash123'; - // Local messenger mock for depositWithConfirmation tests - let depositMessengerMock: jest.Mock; + let depositInfrastructure: jest.Mocked; let depositController: TestablePerpsController; + let depositMockCall: jest.Mock; beforeEach(() => { // Mock DepositService @@ -2402,38 +2403,42 @@ describe('PerpsController', () => { currentDepositId: mockDepositId, }); - // Create a messenger mock that handles network and transaction actions - depositMessengerMock = jest.fn().mockImplementation((action: string) => { - if (action === 'RemoteFeatureFlagController:getState') { - return { - remoteFeatureFlags: { - perpsPerpTradingGeoBlockedCountriesV2: { - blockedRegions: [], + // Create infrastructure mock (controllers no longer on infra) + depositInfrastructure = createMockInfrastructure(); + + // Create messenger mock that handles network + transaction + account controller calls + depositMockCall = jest + .fn() + .mockImplementation((action: string, ..._args: unknown[]) => { + if (action === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + perpsPerpTradingGeoBlockedCountriesV2: { blockedRegions: [] }, }, - }, - }; - } - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.resolve({ - result: Promise.resolve(mockTxHash), - transactionMeta: mockTransactionMeta, - }); - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [ - { - address: mockTransaction.from, - type: 'eip155:eoa', - }, - ]; - } - return undefined; - }); + }; + } + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.resolve(mockTxHash), + transactionMeta: mockTransactionMeta, + }); + } + return undefined; + }); Engine.context.TransactionController.estimateGasFee = jest .fn() @@ -2463,11 +2468,11 @@ describe('PerpsController', () => { }, }; - // Create a controller with the custom messenger for this test suite + // Create a controller with the custom infrastructure for this test suite depositController = new TestablePerpsController({ - messenger: createMockMessenger({ call: depositMessengerMock }), + messenger: createMockMessenger({ call: depositMockCall }), state: getDefaultPerpsControllerState(), - infrastructure: createMockInfrastructure(), + infrastructure: depositInfrastructure, }); }); @@ -2515,7 +2520,7 @@ describe('PerpsController', () => { await depositController.depositWithConfirmation({ amount: '100' }); - expect(depositMessengerMock).toHaveBeenCalledWith( + expect(depositMockCall).toHaveBeenCalledWith( 'NetworkController:findNetworkClientIdByChainId', mockAssetChainId, ); @@ -2529,7 +2534,7 @@ describe('PerpsController', () => { await depositController.depositWithConfirmation({ amount: '100' }); - expect(depositMessengerMock).toHaveBeenCalledWith( + expect(depositMockCall).toHaveBeenCalledWith( 'TransactionController:addTransaction', mockTransaction, { @@ -2579,17 +2584,28 @@ describe('PerpsController', () => { new Map([['hyperliquid', mockProvider]]), ); const mockError = new Error('Network client not found'); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - throw mockError; - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + throw mockError; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); await expect( depositController.depositWithConfirmation({ amount: '100' }), @@ -2601,17 +2617,28 @@ describe('PerpsController', () => { depositController.testSetProviders( new Map([['hyperliquid', mockProvider]]), ); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return undefined; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } return undefined; - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + }, + ); await expect( depositController.depositWithConfirmation({ amount: '100' }), @@ -2632,20 +2659,31 @@ describe('PerpsController', () => { new Map([['hyperliquid', mockProvider]]), ); const mockError = new Error('Transaction failed'); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.reject(mockError); - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); await expect( depositController.depositWithConfirmation({ amount: '100' }), @@ -2661,20 +2699,31 @@ describe('PerpsController', () => { state.lastDepositTransactionId = 'old-tx-id'; }); const mockError = new Error('Network error'); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.reject(mockError); - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); await expect( depositController.depositWithConfirmation({ amount: '100' }), @@ -2692,20 +2741,31 @@ describe('PerpsController', () => { state.lastDepositTransactionId = 'old-tx-id'; }); const mockError = new Error('User denied transaction signature'); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.reject(mockError); - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.reject(mockError); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); await expect( depositController.depositWithConfirmation({ amount: '100' }), @@ -2864,8 +2924,8 @@ describe('PerpsController', () => { placeOrder: true, }); - // placeOrder uses messenger-based submitTransaction with perpsDepositAndOrder type - expect(depositMessengerMock).toHaveBeenCalledWith( + // placeOrder uses messenger-based addTransaction with perpsDepositAndOrder type + expect(depositMockCall).toHaveBeenCalledWith( 'TransactionController:addTransaction', mockTransaction, { @@ -2876,7 +2936,7 @@ describe('PerpsController', () => { }, ); // Should NOT also call with perpsDeposit type - expect(depositMessengerMock).not.toHaveBeenCalledWith( + expect(depositMockCall).not.toHaveBeenCalledWith( 'TransactionController:addTransaction', expect.anything(), expect.objectContaining({ type: 'perpsDeposit' }), @@ -2939,23 +2999,34 @@ describe('PerpsController', () => { // Mock messenger to succeed initially, but result promise rejects const mockError = new Error('Network error occurred'); - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.resolve({ - result: Promise.reject(mockError), - transactionMeta: mockTransactionMeta, - }); - } - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); const { result } = await depositController.depositWithConfirmation({ amount: '100', @@ -3005,24 +3076,34 @@ describe('PerpsController', () => { jest.clearAllMocks(); const mockError = new Error(message); // Mock messenger to succeed initially, but result promise rejects with user cancellation - depositMessengerMock.mockImplementation((action: string) => { - if (action === 'NetworkController:findNetworkClientIdByChainId') { - return mockNetworkClientId; - } - if (action === 'TransactionController:addTransaction') { - return Promise.resolve({ - result: Promise.reject(mockError), - transactionMeta: mockTransactionMeta, - }); - } - if ( - action === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [{ address: mockTransaction.from, type: 'eip155:eoa' }]; - } - return undefined; - }); + depositMockCall.mockImplementation( + (action: string, ..._args: unknown[]) => { + if ( + action === + 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [ + { + address: '0x1234567890123456789012345678901234567890', + type: 'eip155:eoa', + }, + ]; + } + if (action === 'TransactionController:addTransaction') { + return Promise.resolve({ + result: Promise.reject(mockError), + transactionMeta: mockTransactionMeta, + }); + } + if (action === 'NetworkController:findNetworkClientIdByChainId') { + return mockNetworkClientId; + } + if (action === 'RemoteFeatureFlagController:getState') { + return { remoteFeatureFlags: {} }; + } + return undefined; + }, + ); const { result } = await depositController.depositWithConfirmation({ amount: '100', @@ -4313,8 +4394,13 @@ describe('PerpsController', () => { }); it('switches to myx provider successfully', async () => { - // Enable MYX feature flag via remote flags + version gating - const mockCall = jest.fn().mockImplementation((action: string) => { + // Create controller with MYX-enabled mocks + const myxInfrastructure = createMockInfrastructure(); + ( + myxInfrastructure.featureFlags.validateVersionGated as jest.Mock + ).mockReturnValue(true); + // Enable MYX feature flag via messenger + const myxMockCall = jest.fn().mockImplementation((action: string) => { if (action === 'RemoteFeatureFlagController:getState') { return { remoteFeatureFlags: { @@ -4329,14 +4415,8 @@ describe('PerpsController', () => { return undefined; }); - // Create controller with MYX-enabled mocks - const myxInfrastructure = createMockInfrastructure(); - ( - myxInfrastructure.featureFlags.validateVersionGated as jest.Mock - ).mockReturnValue(true); - const myxController = new TestablePerpsController({ - messenger: createMockMessenger({ call: mockCall }), + messenger: createMockMessenger({ call: myxMockCall }), state: getDefaultPerpsControllerState(), infrastructure: myxInfrastructure, }); diff --git a/app/controllers/perps/PerpsController.ts b/app/controllers/perps/PerpsController.ts index 108a1fa8ce0..13160e64ba0 100644 --- a/app/controllers/perps/PerpsController.ts +++ b/app/controllers/perps/PerpsController.ts @@ -1,7 +1,3 @@ -import type { - AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, - AccountTreeControllerSelectedAccountGroupChangeEvent, -} from '@metamask/account-tree-controller'; import { BaseController, ControllerGetStateAction, @@ -10,29 +6,7 @@ import { } from '@metamask/base-controller'; import type { StateChangeListener } from '@metamask/base-controller'; import { ORIGIN_METAMASK } from '@metamask/controller-utils'; -import type { - KeyringControllerGetStateAction, - KeyringControllerSignTypedMessageAction, -} from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; -import type { - NetworkControllerGetStateAction, - NetworkControllerGetNetworkClientByIdAction, - NetworkControllerFindNetworkClientIdByChainIdAction, -} from '@metamask/network-controller'; -import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import type { - RemoteFeatureFlagControllerState, - RemoteFeatureFlagControllerStateChangeEvent, - RemoteFeatureFlagControllerGetStateAction, -} from '@metamask/remote-feature-flag-controller'; -import { - TransactionControllerAddTransactionAction, - TransactionControllerTransactionConfirmedEvent, - TransactionControllerTransactionFailedEvent, - TransactionControllerTransactionSubmittedEvent, - TransactionType, -} from '@metamask/transaction-controller'; import type { Json } from '@metamask/utils'; import { v4 as uuidv4 } from 'uuid'; @@ -129,7 +103,14 @@ import type { PerpsActiveProviderMode, PerpsProviderType, PerpsSelectedPaymentToken, + PerpsRemoteFeatureFlagState, + PerpsTransactionParams, + PerpsAddTransactionOptions, } from './types'; +import type { + PerpsControllerAllowedActions, + PerpsControllerAllowedEvents, +} from './types/messenger'; import type { CandleData } from './types/perps-types'; import { LastTransactionResult, @@ -745,36 +726,14 @@ export type PerpsControllerActions = }; /** - * External actions the PerpsController can call via messenger - */ -export type AllowedActions = - | NetworkControllerGetStateAction - | AuthenticationController.AuthenticationControllerGetBearerToken - | RemoteFeatureFlagControllerGetStateAction - | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction - | KeyringControllerGetStateAction - | KeyringControllerSignTypedMessageAction - | NetworkControllerGetNetworkClientByIdAction - | NetworkControllerFindNetworkClientIdByChainIdAction - | TransactionControllerAddTransactionAction; - -/** - * External events the PerpsController can subscribe to - */ -export type AllowedEvents = - | TransactionControllerTransactionSubmittedEvent - | TransactionControllerTransactionConfirmedEvent - | TransactionControllerTransactionFailedEvent - | RemoteFeatureFlagControllerStateChangeEvent - | AccountTreeControllerSelectedAccountGroupChangeEvent; - -/** - * PerpsController messenger constraints + * PerpsController messenger constraints. + * Includes both PerpsController's own actions/events and + * allowed actions/events from external controllers. */ export type PerpsControllerMessenger = Messenger< 'PerpsController', - PerpsControllerActions | AllowedActions, - PerpsControllerEvents | AllowedEvents + PerpsControllerActions | PerpsControllerAllowedActions, + PerpsControllerEvents | PerpsControllerAllowedEvents >; /** @@ -786,7 +745,8 @@ export type PerpsControllerOptions = { clientConfig?: PerpsControllerConfig; /** * Platform-specific dependencies (required) - * Provides logging, metrics, tracing, stream management, and account utilities. + * Provides logging, metrics, tracing, stream management, and rewards. + * Cross-controller communication uses the messenger pattern. * Must be provided by the platform (mobile/extension) at instantiation time. */ infrastructure: PerpsPlatformDependencies; @@ -970,8 +930,8 @@ export class PerpsController extends BaseController< infrastructure, }; - // Instantiate services with platform dependencies and messenger - // Services that need inter-controller communication receive the messenger + // Instantiate services with platform dependencies + // Services that need cross-controller access receive the messenger this.#tradingService = new TradingService(infrastructure); this.#marketDataService = new MarketDataService(infrastructure); this.#accountService = new AccountService(infrastructure, messenger); @@ -1026,11 +986,12 @@ export class PerpsController extends BaseController< ); } - const featureFlagHandler = - this.refreshEligibilityOnFeatureFlagChange.bind(this); + // Subscribe for the full controller lifetime — intentionally not stored; + // geo-blocking and HIP-3 flag propagation must remain active across + // disconnect → reconnect cycles and must never be torn down. this.messenger.subscribe( 'RemoteFeatureFlagController:stateChange', - featureFlagHandler, + this.refreshEligibilityOnFeatureFlagChange.bind(this), ); this.providers = new Map(); @@ -1183,27 +1144,20 @@ export class PerpsController extends BaseController< * @returns The transaction result containing a hash promise and transaction metadata. */ async #submitTransaction( - txParams: { - from: string; - to?: string; - value?: string; - data?: string; - gas?: string; - }, - options: { - networkClientId: string; - origin?: string; - type?: TransactionType; - skipInitialGasEstimate?: boolean; - }, + txParams: PerpsTransactionParams, + options: PerpsAddTransactionOptions, ): Promise<{ result: Promise; transactionMeta: { id: string; hash?: string }; }> { + // Cast needed: PerpsController uses loose string types for txParams/options + // while TransactionController uses strict branded types (TransactionParams, AddTransactionOptions) return this.messenger.call( 'TransactionController:addTransaction', - txParams, - options, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + txParams as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options as any, ); } @@ -1256,7 +1210,7 @@ export class PerpsController extends BaseController< * @param remoteFeatureFlagControllerState - State from RemoteFeatureFlagController. */ protected refreshEligibilityOnFeatureFlagChange( - remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState, + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState, ): void { this.#featureFlagConfigurationService.refreshEligibility({ remoteFeatureFlagControllerState, @@ -1948,7 +1902,11 @@ export class PerpsController extends BaseController< currentDepositId = depositId; // Get current account address via messenger (outside of update() for proper typing) - const evmAccount = getSelectedEvmAccount(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); const accountAddress = evmAccount?.address ?? 'unknown'; this.update((state) => { @@ -1990,10 +1948,10 @@ export class PerpsController extends BaseController< }; if (placeOrder) { - // Use messenger-based addTransaction to create transaction without navigating to confirmation screen + // Use addTransaction to create transaction without navigating to confirmation screen const addResult = await this.#submitTransaction(transaction, { ...defaultTransactionOptions, - type: TransactionType.perpsDepositAndOrder, + type: 'perpsDepositAndOrder', }); transactionMeta = addResult.transactionMeta; // Return transaction ID immediately (fire-and-forget for caller) @@ -2005,7 +1963,7 @@ export class PerpsController extends BaseController< // The promise will resolve when transaction completes or reject if cancelled/failed const submitResult = await this.#submitTransaction(transaction, { ...defaultTransactionOptions, - type: TransactionType.perpsDeposit, + type: 'perpsDeposit', }); result = submitResult.result; transactionMeta = submitResult.transactionMeta; @@ -2664,7 +2622,11 @@ export class PerpsController extends BaseController< // Watch for account changes via AccountTreeController const accountChangeHandler = (): void => { - const evmAccount = getSelectedEvmAccount(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); const currentAddress = evmAccount?.address ?? null; // If there's cached data from a different account (or no EVM account now), clear it @@ -2816,7 +2778,11 @@ export class PerpsController extends BaseController< } // Get current user address - const evmAccount = getSelectedEvmAccount(this.messenger); + const evmAccount = getSelectedEvmAccount( + this.messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount?.address) { return; } @@ -4328,9 +4294,7 @@ export class PerpsController extends BaseController< slPrice: params.slPrice, tpPrice: params.tpPrice, isTestnet: this.state.isTestnet, - context: this.#createServiceContext('reportOrderToDataLake', { - messenger: this.messenger, - }), + context: this.#createServiceContext('reportOrderToDataLake', {}), retryCount: params.retryCount, _traceId: params._traceId, }); diff --git a/app/controllers/perps/providers/HyperLiquidProvider.test.ts b/app/controllers/perps/providers/HyperLiquidProvider.test.ts index 2a059bf46c8..6e1dd76373c 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.test.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.test.ts @@ -300,6 +300,7 @@ const createMockExchangeClient = (overrides: Record = {}) => ({ // Create shared mock platform dependencies for provider tests const mockPlatformDependencies: PerpsPlatformDependencies = createMockInfrastructure(); + const mockMessenger = createMockMessenger(); /** diff --git a/app/controllers/perps/providers/HyperLiquidProvider.ts b/app/controllers/perps/providers/HyperLiquidProvider.ts index 8ef0489c819..1885529a67a 100644 --- a/app/controllers/perps/providers/HyperLiquidProvider.ts +++ b/app/controllers/perps/providers/HyperLiquidProvider.ts @@ -27,7 +27,6 @@ import { WITHDRAWAL_CONSTANTS, } from '../constants/perpsConfig'; import { PERPS_TRANSACTIONS_HISTORY_CONSTANTS } from '../constants/transactionsHistoryConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { HyperLiquidClientService, @@ -106,6 +105,7 @@ import type { FrontendOrder, SpotMetaResponse, } from '../types/hyperliquid-types'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { ExtendedAssetMeta, ExtendedPerpDex } from '../types/perps-types'; import { aggregateAccountStates } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; @@ -333,6 +333,8 @@ export class HyperLiquidProvider implements PerpsProvider { // Promise-based lock to prevent race conditions in concurrent initialization #initializationPromise: Promise | null = null; + readonly #messenger: PerpsControllerMessengerBase; + constructor(options: { isTestnet?: boolean; hip3Enabled?: boolean; @@ -340,10 +342,11 @@ export class HyperLiquidProvider implements PerpsProvider { blocklistMarkets?: string[]; useDexAbstraction?: boolean; platformDependencies: PerpsPlatformDependencies; - messenger: PerpsControllerMessenger; + messenger: PerpsControllerMessengerBase; initialAssetMapping?: [string, number][]; }) { this.#deps = options.platformDependencies; + this.#messenger = options.messenger; const isTestnet = options.isTestnet ?? false; // Dev-friendly defaults: Enable all markets by default for easier testing (discovery mode) @@ -354,14 +357,16 @@ export class HyperLiquidProvider implements PerpsProvider { // Attempt native balance abstraction, fallback to programmatic transfer if unsupported this.#useDexAbstraction = options.useDexAbstraction ?? true; - // Initialize services with injected platform dependencies and messenger + // Initialize services with injected platform dependencies this.#clientService = new HyperLiquidClientService(this.#deps, { isTestnet, }); this.#walletService = new HyperLiquidWalletService( this.#deps, - options.messenger, - { isTestnet }, + this.#messenger, + { + isTestnet, + }, ); this.#subscriptionService = new HyperLiquidSubscriptionService( this.#clientService, diff --git a/app/controllers/perps/services/AccountService.test.ts b/app/controllers/perps/services/AccountService.test.ts index 128949908c8..934a202841b 100644 --- a/app/controllers/perps/services/AccountService.test.ts +++ b/app/controllers/perps/services/AccountService.test.ts @@ -4,10 +4,7 @@ import { createMockInfrastructure, createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; -import type { - PerpsControllerState, - PerpsControllerMessenger, -} from '../PerpsController'; +import type { PerpsControllerState } from '../PerpsController'; import { PerpsAnalyticsEvent } from '../types'; import type { PerpsProvider, @@ -42,15 +39,15 @@ jest.mock('../perpsErrorCodes', () => ({ WITHDRAW_FAILED: 'WITHDRAW_FAILED', }, })); -// Note: EVM account is now retrieved via dependency injection (deps.controllers.accounts.getSelectedEvmAccount) -// The mock is set up via createMockInfrastructure() in serviceMocks.ts +// Note: EVM account is now retrieved via messenger.call('AccountTreeController:getAccountsFromSelectedAccountGroup') +// The mock is set up via createMockMessenger() in serviceMocks.ts describe('AccountService', () => { let mockProvider: jest.Mocked; let mockContext: ServiceContext; let mockRefreshAccountState: jest.Mock; let mockDeps: PerpsPlatformDependencies; - let mockMessenger: jest.Mocked; + let mockMessenger: ReturnType; let accountService: AccountService; const mockWithdrawParams: WithdrawParams = { diff --git a/app/controllers/perps/services/AccountService.ts b/app/controllers/perps/services/AccountService.ts index 56e1b31c938..f6c121daa1e 100644 --- a/app/controllers/perps/services/AccountService.ts +++ b/app/controllers/perps/services/AccountService.ts @@ -6,7 +6,6 @@ import { } from '../constants/eventNames'; import { USDC_SYMBOL } from '../constants/hyperLiquidConfig'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; import { PerpsAnalyticsEvent, @@ -20,6 +19,7 @@ import type { PerpsPlatformDependencies, } from '../types'; import type { ServiceContext } from './ServiceContext'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import type { TransactionStatus } from '../types/transactionTypes'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; @@ -37,17 +37,17 @@ import { ensureError } from '../utils/errorUtils'; export class AccountService { readonly #deps: PerpsPlatformDependencies; - readonly #messenger: PerpsControllerMessenger; + readonly #messenger: PerpsControllerMessengerBase; /** * Create a new AccountService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param messenger - Messenger for inter-controller communication + * @param messenger - Controller messenger for cross-controller communication. */ constructor( deps: PerpsPlatformDependencies, - messenger: PerpsControllerMessenger, + messenger: PerpsControllerMessengerBase, ) { this.#deps = deps; this.#messenger = messenger; @@ -121,7 +121,11 @@ export class AccountService { const netAmount = Math.max(0, grossAmount - feeAmount); // Get current account address via messenger - const evmAccount = getSelectedEvmAccount(this.#messenger); + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); const accountAddress = evmAccount?.address ?? 'unknown'; this.#deps.debugLogger.log( diff --git a/app/controllers/perps/services/DataLakeService.test.ts b/app/controllers/perps/services/DataLakeService.test.ts index 6ad5aff679b..b45bc6e46aa 100644 --- a/app/controllers/perps/services/DataLakeService.test.ts +++ b/app/controllers/perps/services/DataLakeService.test.ts @@ -4,7 +4,6 @@ import { createMockInfrastructure, createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; -import type { PerpsControllerMessenger } from '../PerpsController'; import type { PerpsPlatformDependencies } from '../types'; import { DataLakeService } from './DataLakeService'; @@ -21,12 +20,32 @@ global.setTimeout = jest.fn((fn: () => void) => { describe('DataLakeService', () => { let mockContext: ServiceContext; let mockDeps: jest.Mocked; - let mockMessenger: jest.Mocked; + let mockMessenger: ReturnType; let dataLakeService: DataLakeService; const mockEvmAccount = createMockEvmAccount(); const mockToken = 'mock-bearer-token'; + /** + * Sets up the default messenger mock that returns a valid account and token. + * Called in beforeEach and after any mid-test jest.clearAllMocks(). + */ + function setupDefaultMessenger() { + (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'AuthenticationController:getBearerToken') { + return Promise.resolve(mockToken); + } + return undefined; + }); + } + beforeEach(() => { + jest.clearAllMocks(); + mockDeps = createMockInfrastructure(); mockMessenger = createMockMessenger(); dataLakeService = new DataLakeService(mockDeps, mockMessenger); @@ -39,19 +58,7 @@ describe('DataLakeService', () => { }, }); - // Configure messenger to return expected values - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'AuthenticationController:getBearerToken') { - return Promise.resolve(mockToken); - } - return undefined; - }); - jest.clearAllMocks(); + setupDefaultMessenger(); }); afterEach(() => { @@ -278,6 +285,8 @@ describe('DataLakeService', () => { expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 1000); jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); await dataLakeService.reportOrder({ action: 'open', @@ -289,6 +298,8 @@ describe('DataLakeService', () => { expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 2000); jest.clearAllMocks(); + setupDefaultMessenger(); + (fetch as jest.Mock).mockRejectedValue(new Error('Network error')); await dataLakeService.reportOrder({ action: 'open', diff --git a/app/controllers/perps/services/DataLakeService.ts b/app/controllers/perps/services/DataLakeService.ts index 0f217666a60..18c0162d1e1 100644 --- a/app/controllers/perps/services/DataLakeService.ts +++ b/app/controllers/perps/services/DataLakeService.ts @@ -5,10 +5,10 @@ import { DATA_LAKE_API_CONFIG, PERPS_CONSTANTS, } from '../constants/perpsConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import { PerpsTraceNames, PerpsTraceOperations } from '../types'; import type { PerpsPlatformDependencies } from '../types'; import type { ServiceContext } from './ServiceContext'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; @@ -19,30 +19,29 @@ import { ensureError } from '../utils/errorUtils'; * Implements exponential backoff retry logic and performance tracing. * Stateless service that operates purely on external API calls. * - * Instance-based service with constructor injection of platform dependencies - * and messenger for inter-controller communication. + * Instance-based service with constructor injection of platform dependencies. */ export class DataLakeService { readonly #deps: PerpsPlatformDependencies; - readonly #messenger: PerpsControllerMessenger; + readonly #messenger: PerpsControllerMessengerBase; /** * Create a new DataLakeService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param messenger - Messenger for inter-controller communication + * @param messenger - Controller messenger for cross-controller communication. */ constructor( deps: PerpsPlatformDependencies, - messenger: PerpsControllerMessenger, + messenger: PerpsControllerMessengerBase, ) { this.#deps = deps; this.#messenger = messenger; } /** - * Get bearer token via messenger + * Get bearer token via DI authentication controller * * @returns The bearer token string for API authentication. */ @@ -132,7 +131,11 @@ export class DataLakeService { try { const token = await this.#getBearerToken(); - const evmAccount = getSelectedEvmAccount(this.#messenger); + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount || !token) { this.#deps.debugLogger.log('DataLake API: Missing requirements', { diff --git a/app/controllers/perps/services/DepositService.test.ts b/app/controllers/perps/services/DepositService.test.ts index 54787792091..7b2dea0bdb1 100644 --- a/app/controllers/perps/services/DepositService.test.ts +++ b/app/controllers/perps/services/DepositService.test.ts @@ -7,7 +7,6 @@ import { createMockInfrastructure, createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; -import type { PerpsControllerMessenger } from '../PerpsController'; import type { PerpsProvider, PerpsPlatformDependencies } from '../types'; import { generateDepositId } from '../utils/idUtils'; import { generateERC20TransferData } from '../utils/transferData'; @@ -37,7 +36,7 @@ jest.mock('@metamask/controller-utils', () => { describe('DepositService', () => { let mockProvider: jest.Mocked; let mockDeps: jest.Mocked; - let mockMessenger: jest.Mocked; + let mockMessenger: ReturnType; let service: DepositService; const mockEvmAccount = createMockEvmAccount(); const mockDepositId = 'deposit-123'; @@ -164,7 +163,7 @@ describe('DepositService', () => { expect(result.transaction.data).toMatch(/^0xa9059cbb/); }); - it('retrieves EVM account from selected account group via messenger', async () => { + it('retrieves EVM account from messenger via accountTree action', async () => { await service.prepareTransaction({ provider: mockProvider, }); diff --git a/app/controllers/perps/services/DepositService.ts b/app/controllers/perps/services/DepositService.ts index 496724e6db1..a4ffe31e519 100644 --- a/app/controllers/perps/services/DepositService.ts +++ b/app/controllers/perps/services/DepositService.ts @@ -1,10 +1,13 @@ import { toHex } from '@metamask/controller-utils'; -import type { TransactionParams } from '@metamask/transaction-controller'; import { parseCaipAssetId } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; -import type { PerpsControllerMessenger } from '../PerpsController'; -import type { PerpsProvider, PerpsPlatformDependencies } from '../types'; +import type { + PerpsProvider, + PerpsPlatformDependencies, + PerpsTransactionParams, +} from '../types'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { generateDepositId } from '../utils/idUtils'; import { generateERC20TransferData } from '../utils/transferData'; @@ -25,17 +28,17 @@ const DEPOSIT_GAS_LIMIT = toHex(100000); export class DepositService { readonly #deps: PerpsPlatformDependencies; - readonly #messenger: PerpsControllerMessenger; + readonly #messenger: PerpsControllerMessengerBase; /** * Create a new DepositService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param messenger - Messenger for inter-controller communication + * @param messenger - Controller messenger for cross-controller communication. */ constructor( deps: PerpsPlatformDependencies, - messenger: PerpsControllerMessenger, + messenger: PerpsControllerMessengerBase, ) { this.#deps = deps; this.#messenger = messenger; @@ -50,7 +53,7 @@ export class DepositService { * @returns Transaction data ready for TransactionController.addTransaction */ async prepareTransaction(options: { provider: PerpsProvider }): Promise<{ - transaction: TransactionParams; + transaction: PerpsTransactionParams; assetChainId: Hex; currentDepositId: string; }> { @@ -73,7 +76,11 @@ export class DepositService { ); // Get EVM account from selected account group via messenger - const evmAccount = getSelectedEvmAccount(this.#messenger); + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount) { throw new Error( 'No EVM-compatible account found in selected account group', @@ -87,7 +94,7 @@ export class DepositService { const tokenAddress = parsedAsset.assetReference as Hex; // Build transaction parameters for TransactionController - const transaction: TransactionParams = { + const transaction: PerpsTransactionParams = { from: accountAddress, to: tokenAddress, value: '0x0', diff --git a/app/controllers/perps/services/FeatureFlagConfigurationService.ts b/app/controllers/perps/services/FeatureFlagConfigurationService.ts index ba5f2adafd3..ee029503549 100644 --- a/app/controllers/perps/services/FeatureFlagConfigurationService.ts +++ b/app/controllers/perps/services/FeatureFlagConfigurationService.ts @@ -1,9 +1,11 @@ -import type { RemoteFeatureFlagControllerState } from '@metamask/remote-feature-flag-controller'; import { hasProperty } from '@metamask/utils'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; import { isVersionGatedFeatureFlag } from '../types'; -import type { PerpsPlatformDependencies } from '../types'; +import type { + PerpsPlatformDependencies, + PerpsRemoteFeatureFlagState, +} from '../types'; import type { ServiceContext } from './ServiceContext'; import { ensureError } from '../utils/errorUtils'; import { validateMarketPattern } from '../utils/marketUtils'; @@ -182,11 +184,11 @@ export class FeatureFlagConfigurationService { * Follows the "sticky remote" pattern: once remote config is loaded, never downgrade to fallback. * * @param options - Configuration object - * @param options.remoteFeatureFlagControllerState - State from RemoteFeatureFlagController + * @param options.remoteFeatureFlagControllerState - Remote feature flag state * @param options.context - ServiceContext providing state access callbacks */ refreshHip3Config(options: { - remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState; + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState; context: ServiceContext; }): void { const { remoteFeatureFlagControllerState, context } = options; @@ -320,11 +322,11 @@ export class FeatureFlagConfigurationService { * Note: Initial eligibility is set in the constructor if fallback regions are provided. * * @param options - Configuration object - * @param options.remoteFeatureFlagControllerState - State from RemoteFeatureFlagController + * @param options.remoteFeatureFlagControllerState - Remote feature flag state * @param options.context - ServiceContext providing callbacks */ refreshEligibility(options: { - remoteFeatureFlagControllerState: RemoteFeatureFlagControllerState; + remoteFeatureFlagControllerState: PerpsRemoteFeatureFlagState; context: ServiceContext; }): void { const { remoteFeatureFlagControllerState, context } = options; diff --git a/app/controllers/perps/services/HyperLiquidWalletService.test.ts b/app/controllers/perps/services/HyperLiquidWalletService.test.ts index 42b75323eb0..3c9363ace64 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.test.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.test.ts @@ -11,37 +11,7 @@ jest.mock('@metamask/keyring-api', () => ({ ), })); -// Mock keyring controller to avoid import issues -jest.mock('@metamask/keyring-controller', () => ({ - SignTypedDataVersion: { - V1: 'V1', - V2: 'V2', - V3: 'V3', - V4: 'V4', - }, -})); - // Mock MetaMask utils -// Mock using the MetaMask mobile testing patterns -const MOCK_SELECTED_ACCOUNT = { - address: '0x1234567890123456789012345678901234567890', -}; - -const MOCK_STORE_STATE = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - selectedAccount: 'test-account-id', - accounts: { - 'test-account-id': MOCK_SELECTED_ACCOUNT, - }, - }, - }, - }, - }, -}; - jest.mock('@metamask/utils', () => ({ parseCaipAccountId: jest.fn((accountId: string) => { const parts = accountId.split(':'); @@ -56,51 +26,6 @@ jest.mock('@metamask/utils', () => ({ ), })); -jest.mock('../../../store', () => ({ - store: { - getState: jest.fn(() => MOCK_STORE_STATE), - }, -})); - -// Mock selectors -jest.mock('../../../selectors/accountsController', () => ({ - selectSelectedInternalAccountAddress: jest.fn( - () => MOCK_SELECTED_ACCOUNT.address, - ), -})); - -jest.mock('../../../selectors/multichainAccounts/accounts', () => ({ - selectSelectedInternalAccountByScope: jest.fn( - () => () => MOCK_SELECTED_ACCOUNT, - ), -})); - -// Mock Engine with proper hoisting -jest.mock('../../../core/Engine', () => { - const mockKeyringController = { - signTypedMessage: jest.fn().mockResolvedValue('0xSignatureResult'), - }; - - const mockAccountTreeController = { - getAccountsFromSelectedAccountGroup: jest.fn().mockReturnValue([ - { - address: '0x1234567890123456789012345678901234567890', - type: 'eip155:1', - }, - ]), - }; - - return { - __esModule: true, - default: { - context: { - KeyringController: mockKeyringController, - AccountTreeController: mockAccountTreeController, - }, - }, - }; -}); - // Mock config jest.mock('../constants/hyperLiquidConfig', () => ({ getChainId: jest.fn((isTestnet: boolean) => (isTestnet ? '421614' : '42161')), @@ -117,17 +42,16 @@ import type { CaipAccountId } from '@metamask/utils'; import { createMockInfrastructure, - createMockMessenger, createMockEvmAccount, + createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; -import type { PerpsControllerMessenger } from '../PerpsController'; import { HyperLiquidWalletService } from './HyperLiquidWalletService'; describe('HyperLiquidWalletService', () => { let service: HyperLiquidWalletService; let mockDeps: ReturnType; - let mockMessenger: jest.Mocked; + let mockMessenger: ReturnType; const mockEvmAccount = createMockEvmAccount(); beforeEach(() => { @@ -272,7 +196,7 @@ describe('HyperLiquidWalletService', () => { }); it('should throw error when no account selected', async () => { - // Mock messenger to return empty array (no account selected) + // Mock accountTree to return empty array (no account selected) (mockMessenger.call as jest.Mock).mockImplementation( (action: string) => { if ( @@ -281,6 +205,12 @@ describe('HyperLiquidWalletService', () => { ) { return []; } + if (action === 'KeyringController:getState') { + return { isUnlocked: true }; + } + if (action === 'KeyringController:signTypedMessage') { + return Promise.resolve('0xSignatureResult'); + } return undefined; }, ); @@ -310,8 +240,11 @@ describe('HyperLiquidWalletService', () => { }, ); + // Need to recreate the adapter after changing the mock + const freshAdapter = service.createWalletAdapter(); + await expect( - walletAdapter.signTypedData(mockTypedDataParams), + freshAdapter.signTypedData(mockTypedDataParams), ).rejects.toThrow('Signing failed'); }); }); @@ -321,7 +254,6 @@ describe('HyperLiquidWalletService', () => { it('should get current account ID for mainnet', async () => { const accountId = await service.getCurrentAccountId(); - // Uses address from mockMessenger's AccountTreeController:getAccountsFromSelectedAccountGroup expect(accountId).toBe(`eip155:42161:${mockEvmAccount.address}`); }); @@ -334,7 +266,6 @@ describe('HyperLiquidWalletService', () => { }); it('should throw error when getting account ID with no selected account', async () => { - // Mock messenger to return empty array (no account selected) (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -359,7 +290,6 @@ describe('HyperLiquidWalletService', () => { }); it('should throw error for invalid address format', () => { - // Mock isValidHexAddress at the module level const { isValidHexAddress } = jest.requireMock('@metamask/utils'); isValidHexAddress.mockReturnValueOnce(false); @@ -382,7 +312,6 @@ describe('HyperLiquidWalletService', () => { it('should get user address with default fallback', async () => { const address = await service.getUserAddressWithDefault(); - // Uses address from mockMessenger's AccountTreeController:getAccountsFromSelectedAccountGroup expect(address).toBe(mockEvmAccount.address); }); }); @@ -412,8 +341,7 @@ describe('HyperLiquidWalletService', () => { }); describe('Error Handling', () => { - it('should handle store state errors gracefully', async () => { - // Mock messenger to throw an error + it('should handle accountTree errors gracefully', async () => { (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -429,7 +357,6 @@ describe('HyperLiquidWalletService', () => { }); it('should handle malformed CAIP account IDs', () => { - // Mock parseCaipAccountId at the module level const { parseCaipAccountId } = jest.requireMock('@metamask/utils'); parseCaipAccountId.mockImplementationOnce(() => { throw new Error('Invalid CAIP account ID'); @@ -474,7 +401,6 @@ describe('HyperLiquidWalletService', () => { await expect(walletAdapter.signTypedData(mockTypedData)).rejects.toThrow( 'KEYRING_LOCKED', ); - // signTypedMessage should NOT have been called expect(mockMessenger.call).not.toHaveBeenCalledWith( 'KeyringController:signTypedMessage', expect.anything(), @@ -497,7 +423,6 @@ describe('HyperLiquidWalletService', () => { it('should handle keyring controller initialization errors', async () => { const walletAdapter = service.createWalletAdapter(); - // Override messenger.call for the signing call (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' diff --git a/app/controllers/perps/services/HyperLiquidWalletService.ts b/app/controllers/perps/services/HyperLiquidWalletService.ts index fd60d4a236c..d1ac687ce7a 100644 --- a/app/controllers/perps/services/HyperLiquidWalletService.ts +++ b/app/controllers/perps/services/HyperLiquidWalletService.ts @@ -1,12 +1,13 @@ -import { SignTypedDataVersion } from '@metamask/keyring-controller'; -import type { TypedMessageParams } from '@metamask/keyring-controller'; import { parseCaipAccountId, isValidHexAddress } from '@metamask/utils'; import type { CaipAccountId, Hex } from '@metamask/utils'; import { getChainId } from '../constants/hyperLiquidConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import { PERPS_ERROR_CODES } from '../perpsErrorCodes'; -import type { PerpsPlatformDependencies } from '../types'; +import type { + PerpsPlatformDependencies, + PerpsTypedMessageParams, +} from '../types'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccount } from '../utils/accountUtils'; /** @@ -19,12 +20,11 @@ export class HyperLiquidWalletService { // Platform dependencies for observability readonly #deps: PerpsPlatformDependencies; - // Messenger for inter-controller communication - readonly #messenger: PerpsControllerMessenger; + readonly #messenger: PerpsControllerMessengerBase; constructor( deps: PerpsPlatformDependencies, - messenger: PerpsControllerMessenger, + messenger: PerpsControllerMessengerBase, options: { isTestnet?: boolean } = {}, ) { this.#deps = deps; @@ -42,19 +42,23 @@ export class HyperLiquidWalletService { } /** - * Sign typed data via messenger + * Sign typed data via DI keyring controller * * @param msgParams - The typed message parameters including data and sender address. * @returns The signature string. */ - async #signTypedMessage(msgParams: TypedMessageParams): Promise { + async #signTypedMessage(msgParams: PerpsTypedMessageParams): Promise { if (!this.isKeyringUnlocked()) { throw new Error(PERPS_ERROR_CODES.KEYRING_LOCKED); } + // Cast needed: PerpsTypedMessageParams uses loose `data: unknown` type + // while KeyringController uses strict TypedMessageParams / SignTypedDataVersion return this.#messenger.call( 'KeyringController:signTypedMessage', - msgParams, - SignTypedDataVersion.V4, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + msgParams as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 'V4' as any, ); } @@ -81,8 +85,12 @@ export class HyperLiquidWalletService { }) => Promise; getChainId?: () => Promise; } { - // Get current EVM account using messenger - const evmAccount = getSelectedEvmAccount(this.#messenger); + // Get current EVM account via DI accountTree + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -107,7 +115,11 @@ export class HyperLiquidWalletService { }): Promise => { // Get FRESH account on every sign to handle account switches // This prevents race conditions where wallet adapter was created with old account - const currentEvmAccount = getSelectedEvmAccount(this.#messenger); + const currentEvmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!currentEvmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); @@ -151,7 +163,11 @@ export class HyperLiquidWalletService { * @returns The CAIP account ID for the current EVM account. */ public async getCurrentAccountId(): Promise { - const evmAccount = getSelectedEvmAccount(this.#messenger); + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount?.address) { throw new Error(PERPS_ERROR_CODES.NO_ACCOUNT_SELECTED); diff --git a/app/controllers/perps/services/RewardsIntegrationService.test.ts b/app/controllers/perps/services/RewardsIntegrationService.test.ts index 4743bb20093..c418c3c8c1e 100644 --- a/app/controllers/perps/services/RewardsIntegrationService.test.ts +++ b/app/controllers/perps/services/RewardsIntegrationService.test.ts @@ -3,20 +3,48 @@ import { createMockInfrastructure, createMockMessenger, } from '../../../components/UI/Perps/__mocks__/serviceMocks'; -import type { PerpsControllerMessenger } from '../PerpsController'; import type { PerpsPlatformDependencies } from '../types'; import { RewardsIntegrationService } from './RewardsIntegrationService'; describe('RewardsIntegrationService', () => { - let mockMessenger: jest.Mocked; let mockDeps: jest.Mocked; + let mockMessenger: ReturnType; let service: RewardsIntegrationService; const mockEvmAccount = createMockEvmAccount(); + /** + * Helper to set up mockMessenger.call with standard defaults, + * plus optional overrides for specific actions. + */ + const setupMessengerDefaults = (overrides: Record = {}) => { + (mockMessenger.call as jest.Mock).mockImplementation( + (action: string, ...args: unknown[]) => { + if (action in overrides) { + const val = overrides[action]; + return typeof val === 'function' + ? (val as (...a: unknown[]) => unknown)(...args) + : val; + } + if ( + action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' + ) { + return [mockEvmAccount]; + } + if (action === 'NetworkController:getState') { + return { selectedNetworkClientId: 'mainnet' }; + } + if (action === 'NetworkController:getNetworkClientById') { + return { configuration: { chainId: '0x1' } }; + } + return undefined; + }, + ); + }; + beforeEach(() => { - mockMessenger = createMockMessenger(); mockDeps = createMockInfrastructure(); + mockMessenger = createMockMessenger(); service = new RewardsIntegrationService(mockDeps, mockMessenger); jest.clearAllMocks(); @@ -30,29 +58,15 @@ describe('RewardsIntegrationService', () => { it('calculates fee discount successfully with valid discount', async () => { const mockDiscountBips = 6500; // 65% - // Configure messenger to return expected values - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { - return { selectedNetworkClientId: 'mainnet' }; - } - if (action === 'NetworkController:getNetworkClientById') { - return { configuration: { chainId: '0x1' } }; - } - return undefined; - }); - (mockDeps.rewards.getFeeDiscount as jest.Mock).mockResolvedValue( - mockDiscountBips, - ); + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(mockDiscountBips); const result = await service.calculateUserFeeDiscount(); expect(result).toBe(6500); - expect(mockDeps.rewards.getFeeDiscount).toHaveBeenCalledWith( + expect(mockDeps.rewards.getPerpsDiscountForAccount).toHaveBeenCalledWith( expect.stringMatching(/^eip155:1:0x/), ); expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( @@ -64,22 +78,11 @@ describe('RewardsIntegrationService', () => { ); }); - it('returns undefined when no discount available', async () => { - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { - return { selectedNetworkClientId: 'mainnet' }; - } - if (action === 'NetworkController:getNetworkClientById') { - return { configuration: { chainId: '0x1' } }; - } - return undefined; - }); - (mockDeps.rewards.getFeeDiscount as jest.Mock).mockResolvedValue(0); + it('returns 0 when no discount available', async () => { + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(0); const result = await service.calculateUserFeeDiscount(); @@ -87,13 +90,8 @@ describe('RewardsIntegrationService', () => { }); it('returns undefined when no EVM account found', async () => { - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return []; - } - return undefined; + setupMessengerDefaults({ + 'AccountTreeController:getAccountsFromSelectedAccountGroup': [], }); const result = await service.calculateUserFeeDiscount(); @@ -102,51 +100,33 @@ describe('RewardsIntegrationService', () => { expect(mockDeps.debugLogger.log).toHaveBeenCalledWith( 'RewardsIntegrationService: No EVM account found for fee discount', ); - expect(mockDeps.rewards.getFeeDiscount).not.toHaveBeenCalled(); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); }); it('returns undefined when chain ID not found', async () => { - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { - return { selectedNetworkClientId: 'mainnet' }; - } - if (action === 'NetworkController:getNetworkClientById') { + setupMessengerDefaults({ + 'NetworkController:getNetworkClientById': () => { throw new Error('Network client not found'); - } - return undefined; + }, }); const result = await service.calculateUserFeeDiscount(); expect(result).toBeUndefined(); - expect(mockDeps.rewards.getFeeDiscount).not.toHaveBeenCalled(); + expect( + mockDeps.rewards.getPerpsDiscountForAccount, + ).not.toHaveBeenCalled(); }); it('returns undefined when getFeeDiscount throws error', async () => { const mockError = new Error('Rewards API error'); - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { - return { selectedNetworkClientId: 'mainnet' }; - } - if (action === 'NetworkController:getNetworkClientById') { - return { configuration: { chainId: '0x1' } }; - } - return undefined; - }); - (mockDeps.rewards.getFeeDiscount as jest.Mock).mockRejectedValue( - mockError, - ); + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockRejectedValue(mockError); const result = await service.calculateUserFeeDiscount(); @@ -164,16 +144,10 @@ describe('RewardsIntegrationService', () => { it('returns undefined when NetworkController throws error', async () => { const mockError = new Error('Network error'); - (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { - if ( - action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { + setupMessengerDefaults({ + 'NetworkController:getState': () => { throw mockError; - } - return undefined; + }, }); const result = await service.calculateUserFeeDiscount(); @@ -190,7 +164,6 @@ describe('RewardsIntegrationService', () => { ]; for (const chain of chains) { - // Reset only specific mocks, keeping mockDeps intact jest.clearAllMocks(); mockDeps = createMockInfrastructure(); mockMessenger = createMockMessenger(); @@ -213,7 +186,9 @@ describe('RewardsIntegrationService', () => { return undefined; }, ); - (mockDeps.rewards.getFeeDiscount as jest.Mock).mockResolvedValue(5000); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(5000); const result = await service.calculateUserFeeDiscount(); @@ -233,26 +208,10 @@ describe('RewardsIntegrationService', () => { for (const testCase of testCases) { jest.clearAllMocks(); - (mockMessenger.call as jest.Mock).mockImplementation( - (action: string) => { - if ( - action === - 'AccountTreeController:getAccountsFromSelectedAccountGroup' - ) { - return [mockEvmAccount]; - } - if (action === 'NetworkController:getState') { - return { selectedNetworkClientId: 'mainnet' }; - } - if (action === 'NetworkController:getNetworkClientById') { - return { configuration: { chainId: '0x1' } }; - } - return undefined; - }, - ); - (mockDeps.rewards.getFeeDiscount as jest.Mock).mockResolvedValue( - testCase.bips, - ); + setupMessengerDefaults(); + ( + mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock + ).mockResolvedValue(testCase.bips); await service.calculateUserFeeDiscount(); @@ -273,7 +232,7 @@ describe('RewardsIntegrationService', () => { const mockMessenger2 = createMockMessenger(); const service2 = new RewardsIntegrationService(mockDeps2, mockMessenger2); - // First service - mock messenger to return empty array (no EVM account) + // First service - no EVM account (mockMessenger.call as jest.Mock).mockImplementation((action: string) => { if ( action === 'AccountTreeController:getAccountsFromSelectedAccountGroup' @@ -284,7 +243,7 @@ describe('RewardsIntegrationService', () => { }); await service.calculateUserFeeDiscount(); - // Second service - uses same mock pattern + // Second service - no EVM account (mockMessenger2.call as jest.Mock).mockImplementation( (action: string) => { if ( diff --git a/app/controllers/perps/services/RewardsIntegrationService.ts b/app/controllers/perps/services/RewardsIntegrationService.ts index 3754abed5a1..ffd8eadbab2 100644 --- a/app/controllers/perps/services/RewardsIntegrationService.ts +++ b/app/controllers/perps/services/RewardsIntegrationService.ts @@ -1,6 +1,6 @@ import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import type { PerpsControllerMessenger } from '../PerpsController'; import type { PerpsPlatformDependencies } from '../types'; +import type { PerpsControllerMessengerBase } from '../types/messenger'; import { getSelectedEvmAccount } from '../utils/accountUtils'; import { ensureError } from '../utils/errorUtils'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; @@ -11,30 +11,29 @@ import { formatAccountToCaipAccountId } from '../utils/rewardsUtils'; * Handles rewards-related operations and fee discount calculations. * Stateless service that coordinates with RewardsController and NetworkController. * - * Instance-based service with constructor injection of platform dependencies - * and messenger for inter-controller communication. + * Instance-based service with constructor injection of platform dependencies. */ export class RewardsIntegrationService { readonly #deps: PerpsPlatformDependencies; - readonly #messenger: PerpsControllerMessenger; + readonly #messenger: PerpsControllerMessengerBase; /** * Create a new RewardsIntegrationService instance * * @param deps - Platform dependencies for logging, metrics, etc. - * @param messenger - Messenger for inter-controller communication + * @param messenger - Controller messenger for cross-controller communication. */ constructor( deps: PerpsPlatformDependencies, - messenger: PerpsControllerMessenger, + messenger: PerpsControllerMessengerBase, ) { this.#deps = deps; this.#messenger = messenger; } /** - * Get chain ID for a network client via messenger + * Get chain ID for a network client via DI network controller * * @param networkClientId - The network client identifier to look up. * @returns The chain ID string, or undefined if the network client is not found. @@ -60,7 +59,11 @@ export class RewardsIntegrationService { */ async calculateUserFeeDiscount(): Promise { try { - const evmAccount = getSelectedEvmAccount(this.#messenger); + const evmAccount = getSelectedEvmAccount( + this.#messenger.call( + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + ), + ); if (!evmAccount) { this.#deps.debugLogger.log( @@ -69,7 +72,7 @@ export class RewardsIntegrationService { return undefined; } - // Get the chain ID using messenger + // Get the chain ID via DI network controller const networkState = this.#messenger.call('NetworkController:getState'); const { selectedNetworkClientId } = networkState; const chainId = this.#getChainIdForNetwork(selectedNetworkClientId); @@ -115,9 +118,9 @@ export class RewardsIntegrationService { return undefined; } - // Use rewards from deps (stays as DI - no messenger action in core) + // Use rewards via DI (no RewardsController in Core yet) const discountBips = - await this.#deps.rewards.getFeeDiscount(caipAccountId); + await this.#deps.rewards.getPerpsDiscountForAccount(caipAccountId); this.#deps.debugLogger.log( 'RewardsIntegrationService: Fee discount calculated', diff --git a/app/controllers/perps/services/ServiceContext.ts b/app/controllers/perps/services/ServiceContext.ts index 1a0aa13ec2a..a14cd024b93 100644 --- a/app/controllers/perps/services/ServiceContext.ts +++ b/app/controllers/perps/services/ServiceContext.ts @@ -1,7 +1,4 @@ -import type { - PerpsControllerState, - PerpsControllerMessenger, -} from '../PerpsController'; +import type { PerpsControllerState } from '../PerpsController'; import type { Order, Position } from '../types'; /** @@ -56,13 +53,6 @@ export type ServiceContext = { getState: () => PerpsControllerState; }; - /** - * Messenger for controller communication (optional) - * Required by: DataLakeService (getBearerToken) - * Note: TradingService now receives this via setControllerDependencies() - */ - messenger?: PerpsControllerMessenger; - /** * Query functions for dependent data * Required by: Operations that need to fetch related data diff --git a/app/controllers/perps/types/index.ts b/app/controllers/perps/types/index.ts index ca9c541810f..930a5439e77 100644 --- a/app/controllers/perps/types/index.ts +++ b/app/controllers/perps/types/index.ts @@ -1377,21 +1377,56 @@ export type PerpsTracer = { }; // ============================================================================ -// Rewards Interface +// Minimal local types for cross-controller DI (no external controller imports) // ============================================================================ /** - * Rewards controller operations required by Perps. - * Provides fee discount capabilities for MetaMask rewards program. + * Minimal typed-message params passed to keyring for EIP-712 signing. + * Structurally matches KeyringController's TypedMessageParams. */ -export type PerpsRewardsOperations = { - /** - * Get fee discount for an account. - * Returns discount in basis points (e.g., 6500 = 65% discount) - */ - getFeeDiscount( - caipAccountId: `${string}:${string}:${string}`, - ): Promise; +export type PerpsTypedMessageParams = { + from: string; + data: unknown; +}; + +/** + * Minimal transaction params passed to TransactionController.addTransaction. + * Only the fields PerpsController actually sets. + */ +export type PerpsTransactionParams = { + from: string; + to?: string; + value?: string; + data?: string; + gas?: string; +}; + +/** + * Options passed to TransactionController.addTransaction. + */ +export type PerpsAddTransactionOptions = { + networkClientId: string; + origin?: string; + type?: string; + skipInitialGasEstimate?: boolean; +}; + +/** + * Minimal account shape read from AccountTreeController. + * Only the fields PerpsController and its services actually use. + */ +export type PerpsInternalAccount = { + address: string; + type: string; + id: string; +}; + +/** + * Minimal remote feature flag state shape. + * Only the remoteFeatureFlags record is needed by PerpsController. + */ +export type PerpsRemoteFeatureFlagState = { + remoteFeatureFlags: Record; }; /** @@ -1400,10 +1435,11 @@ export type PerpsRewardsOperations = { * Architecture: * - Observability: logger, debugLogger, metrics, performance, tracer * - Platform: streamManager (mobile/extension specific) - * - Rewards: fee discount operations * - Cache: cache invalidation for standalone queries + * - Rewards: delegated rewards interaction (DI — no RewardsController in Core yet) * - * Controller access uses messenger pattern (messenger.call()). + * Cross-controller communication uses the messenger pattern (messenger.call). + * Only rewards remains as DI because RewardsController is not yet in Core. */ export type PerpsPlatformDependencies = { // === Observability (stateless utilities) === @@ -1416,9 +1452,6 @@ export type PerpsPlatformDependencies = { // === Platform Services (mobile/extension specific) === streamManager: PerpsStreamManager; - // === Rewards (no standard messenger action in core) === - rewards: PerpsRewardsOperations; - // === Feature Flags (platform-specific version gating) === featureFlags: { /** @@ -1437,6 +1470,17 @@ export type PerpsPlatformDependencies = { // === Cache Invalidation (for standalone query caches) === cacheInvalidator: PerpsCacheInvalidator; + + // === Rewards (DI — no RewardsController in Core yet) === + rewards: { + /** + * Get fee discount for an account from the RewardsController. + * Returns discount in basis points (e.g., 6500 = 65% discount) + */ + getPerpsDiscountForAccount( + caipAccountId: `${string}:${string}:${string}`, + ): Promise; + }; }; /** diff --git a/app/controllers/perps/types/messenger.ts b/app/controllers/perps/types/messenger.ts new file mode 100644 index 00000000000..182d030ca4c --- /dev/null +++ b/app/controllers/perps/types/messenger.ts @@ -0,0 +1,57 @@ +import type { + AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, + AccountTreeControllerSelectedAccountGroupChangeEvent, +} from '@metamask/account-tree-controller'; +import type { + KeyringControllerGetStateAction, + KeyringControllerSignTypedMessageAction, +} from '@metamask/keyring-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetStateAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerFindNetworkClientIdByChainIdAction, +} from '@metamask/network-controller'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { + RemoteFeatureFlagControllerGetStateAction, + RemoteFeatureFlagControllerStateChangeEvent, +} from '@metamask/remote-feature-flag-controller'; +import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; + +/** + * Actions from other controllers that PerpsController is allowed to call. + */ +export type PerpsControllerAllowedActions = + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerFindNetworkClientIdByChainIdAction + | KeyringControllerGetStateAction + | KeyringControllerSignTypedMessageAction + | TransactionControllerAddTransactionAction + | RemoteFeatureFlagControllerGetStateAction + | AccountTreeControllerGetAccountsFromSelectedAccountGroupAction + | AuthenticationController.AuthenticationControllerGetBearerToken; + +/** + * Events from other controllers that PerpsController is allowed to subscribe to. + */ +export type PerpsControllerAllowedEvents = + | RemoteFeatureFlagControllerStateChangeEvent + | AccountTreeControllerSelectedAccountGroupChangeEvent; + +/** + * The messenger type used by PerpsController and its services. + * Defined here (rather than in PerpsController.ts) to avoid circular imports + * between the controller and service files. + * + * The first two type parameters (Actions, Events) are filled in by + * PerpsController.ts when it unions in its own actions/events. + * Services use this base type directly since they only need the allowed + * external actions/events. + */ +export type PerpsControllerMessengerBase = Messenger< + 'PerpsController', + PerpsControllerAllowedActions, + PerpsControllerAllowedEvents +>; diff --git a/app/controllers/perps/utils/accountUtils.ts b/app/controllers/perps/utils/accountUtils.ts index 2c9f2cd6665..377cdde6f1d 100644 --- a/app/controllers/perps/utils/accountUtils.ts +++ b/app/controllers/perps/utils/accountUtils.ts @@ -2,16 +2,20 @@ * Account utilities for Perps components * Handles account selection and EVM account filtering */ -import { isEvmAccountType } from '@metamask/keyring-api'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { PERPS_CONSTANTS } from '../constants/perpsConfig'; -import { PerpsControllerMessenger } from '../PerpsController'; -import type { AccountState } from '../types'; +import type { AccountState, PerpsInternalAccount } from '../types'; + +const EVM_ACCOUNT_TYPES = new Set(['eip155:eoa', 'eip155:erc4337']); + +function isEvmAccountType(type: string): boolean { + return EVM_ACCOUNT_TYPES.has(type); +} export function findEvmAccount( - accounts: InternalAccount[], -): InternalAccount | null { + accounts: (InternalAccount | PerpsInternalAccount)[], +): { address: string; type: string } | null { const evmAccount = accounts.find( (account) => account && isEvmAccountType(account.type as InternalAccount['type']), @@ -20,19 +24,16 @@ export function findEvmAccount( } export function getEvmAccountFromAccountGroup( - accounts: InternalAccount[], + accounts: (InternalAccount | PerpsInternalAccount)[], ): { address: string } | undefined { const evmAccount = findEvmAccount(accounts); return evmAccount ? { address: evmAccount.address } : undefined; } export function getSelectedEvmAccount( - messenger: PerpsControllerMessenger, + accounts: (InternalAccount | PerpsInternalAccount)[], ): { address: string } | undefined { - const accounts = messenger.call( - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - ); - return getEvmAccountFromAccountGroup(accounts as InternalAccount[]); + return getEvmAccountFromAccountGroup(accounts); } export type ReturnOnEquityInput = { diff --git a/app/controllers/perps/utils/rewardsUtils.test.ts b/app/controllers/perps/utils/rewardsUtils.test.ts new file mode 100644 index 00000000000..202afe684f3 --- /dev/null +++ b/app/controllers/perps/utils/rewardsUtils.test.ts @@ -0,0 +1,102 @@ +import { + formatAccountToCaipAccountId, + isCaipAccountId, + handleRewardsError, +} from './rewardsUtils'; +import type { PerpsLogger } from '../types'; + +describe('rewardsUtils', () => { + describe('formatAccountToCaipAccountId', () => { + it('formats hex chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '0xa4b1', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('formats decimal chain ID and address to CAIP-10', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '42161', + ); + expect(result).toMatch(/^eip155:42161:0x/); + }); + + it('returns null for invalid chain ID (NaN) and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'abc', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('Invalid chain ID: abc'), + }), + expect.any(Object), + ); + }); + + it('returns null for empty chain ID and logs error', () => { + const mockLogger: PerpsLogger = { + error: jest.fn(), + }; + + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + '', + mockLogger, + ); + + expect(result).toBeNull(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + + it('returns null without logger for invalid chain ID', () => { + const result = formatAccountToCaipAccountId( + '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B', + 'not-a-number', + ); + + expect(result).toBeNull(); + }); + }); + + describe('isCaipAccountId', () => { + it('returns true for valid CAIP-10 account ID', () => { + expect(isCaipAccountId('eip155:1:0xABC')).toBe(true); + }); + + it('returns false for non-string value', () => { + expect(isCaipAccountId(123)).toBe(false); + expect(isCaipAccountId(null)).toBe(false); + }); + + it('returns false for non-eip155 namespace', () => { + expect(isCaipAccountId('solana:1:abc')).toBe(false); + }); + + it('returns false for string with fewer than 3 parts', () => { + expect(isCaipAccountId('eip155:1')).toBe(false); + }); + }); + + describe('handleRewardsError', () => { + it('returns user-friendly error message', () => { + const result = handleRewardsError(new Error('test')); + expect(result).toBe('Rewards operation failed'); + }); + + it('logs error when logger is provided', () => { + const mockLogger: PerpsLogger = { error: jest.fn() }; + handleRewardsError(new Error('test'), mockLogger, { key: 'value' }); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/app/controllers/perps/utils/rewardsUtils.ts b/app/controllers/perps/utils/rewardsUtils.ts index 487c706a9e1..afb4f2c37aa 100644 --- a/app/controllers/perps/utils/rewardsUtils.ts +++ b/app/controllers/perps/utils/rewardsUtils.ts @@ -5,7 +5,6 @@ * Portable: no mobile-specific imports. * Logger is injected as optional parameter for platform-agnostic error reporting. */ -import { formatChainIdToCaip } from '@metamask/bridge-controller'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import { toCaipAccountId, @@ -16,6 +15,23 @@ import { import type { PerpsLogger } from '../types'; import { ensureError } from './errorUtils'; +/** + * Converts a numeric or hex chain ID to a CAIP-2 chain ID string. + * e.g. '0x1' → 'eip155:1', '42161' → 'eip155:42161' + * + * @param chainId - Numeric string or hex string chain ID. + * @returns CAIP-2 formatted chain ID. + */ +function formatChainIdToCaip(chainId: string): string { + const decimal = chainId.startsWith('0x') + ? parseInt(chainId, 16) + : parseInt(chainId, 10); + if (isNaN(decimal)) { + throw new Error(`Invalid chain ID: ${chainId}`); + } + return `eip155:${decimal}`; +} + /** * Formats an address to CAIP-10 account ID format * @@ -35,7 +51,7 @@ export const formatAccountToCaipAccountId = ( logger?: PerpsLogger, ): CaipAccountId | null => { try { - const caipChainId = formatChainIdToCaip(chainId); + const caipChainId = formatChainIdToCaip(chainId) as `${string}:${string}`; const { namespace, reference } = parseCaipChainId(caipChainId); // Normalize EVM addresses to checksummed format for consistent CAIP IDs diff --git a/app/core/Engine/messengers/perps-controller-messenger/index.ts b/app/core/Engine/messengers/perps-controller-messenger/index.ts index 1d86a21b68b..6d63c356751 100644 --- a/app/core/Engine/messengers/perps-controller-messenger/index.ts +++ b/app/core/Engine/messengers/perps-controller-messenger/index.ts @@ -9,6 +9,12 @@ import { /** * Get the PerpsControllerMessenger for the PerpsController. * + * PerpsController uses the messenger for all cross-controller communication: + * NetworkController, KeyringController, TransactionController, + * RemoteFeatureFlagController, AccountTreeController, AuthenticationController. + * The root messenger already registers actions for these controllers, + * so the child messenger can call them through the parent. + * * @param rootExtendedMessenger - The root extended messenger. * @returns The PerpsControllerMessenger. */ @@ -27,19 +33,16 @@ export function getPerpsControllerMessenger( rootExtendedMessenger.delegate({ actions: [ 'NetworkController:getState', - 'AuthenticationController:getBearerToken', - 'RemoteFeatureFlagController:getState', - 'AccountTreeController:getAccountsFromSelectedAccountGroup', - 'KeyringController:getState', - 'KeyringController:signTypedMessage', 'NetworkController:getNetworkClientById', 'NetworkController:findNetworkClientIdByChainId', + 'KeyringController:getState', + 'KeyringController:signTypedMessage', 'TransactionController:addTransaction', + 'RemoteFeatureFlagController:getState', + 'AccountTreeController:getAccountsFromSelectedAccountGroup', + 'AuthenticationController:getBearerToken', ], events: [ - 'TransactionController:transactionSubmitted', - 'TransactionController:transactionConfirmed', - 'TransactionController:transactionFailed', 'RemoteFeatureFlagController:stateChange', 'AccountTreeController:selectedAccountGroupChange', ], diff --git a/scripts/perps/validate-core-sync.sh b/scripts/perps/validate-core-sync.sh index a8aa87fd449..659a29c918a 100755 --- a/scripts/perps/validate-core-sync.sh +++ b/scripts/perps/validate-core-sync.sh @@ -30,7 +30,7 @@ PERPS_SRC="app/controllers/perps" PERPS_DEST="packages/perps-controller/src" WORKSPACE="@metamask/perps-controller" -STEP_COUNT=7 +STEP_COUNT=9 STEP_RESULTS=() STEP_TIMES=() STEP_LABELS=() @@ -166,7 +166,7 @@ if [[ -z "$CORE_PATH" ]]; then fi if $SKIP_BUILD; then - STEP_COUNT=6 + STEP_COUNT=8 fi # ─── Step functions ───────────────────────────────────────────────────────────── @@ -212,6 +212,52 @@ step_preflight() { return $errors } +compute_source_checksum() { + local dir="$1" + find "$dir" -name '*.ts' -not -name '*.test.*' -print0 \ + | sort -z \ + | xargs -0 shasum -a 256 \ + | shasum -a 256 \ + | cut -d' ' -f1 +} + +step_conflict_check() { + local sync_state="$CORE_PATH/packages/perps-controller/.sync-state.json" + if [[ ! -f "$sync_state" ]]; then + echo "OK: No previous sync state — first sync" + return 0 + fi + + # Check git-level changes since last sync + local last_core_commit + last_core_commit=$(jq -r '.lastSyncedCoreCommit // empty' "$sync_state") + if [[ -n "$last_core_commit" ]]; then + local core_changes + core_changes=$(cd "$CORE_PATH" && git diff --name-only "$last_core_commit"..HEAD -- packages/perps-controller/src/ 2>/dev/null | wc -l | tr -d ' ') + if (( core_changes > 0 )); then + echo "WARN: Core has $core_changes committed file(s) changed in perps-controller/src/ since last sync ($last_core_commit)" + echo "WARN: Review these changes before syncing to avoid overwriting Core-only edits" + else + echo "OK: No committed Core changes since last sync" + fi + fi + + # Check source fingerprint for uncommitted edits + local stored_checksum + stored_checksum=$(jq -r '.sourceChecksum // empty' "$sync_state") + if [[ -n "$stored_checksum" ]]; then + local current_checksum + current_checksum=$(compute_source_checksum "$CORE_PATH/$PERPS_DEST") + if [[ "$current_checksum" != "$stored_checksum" ]]; then + echo "WARN: Core source checksum mismatch — files were edited since last sync" + echo "WARN: stored: $stored_checksum" + echo "WARN: current: $current_checksum" + else + echo "OK: Core source checksum matches last sync" + fi + fi +} + step_copy() { # Use --filter rules: excludes before includes, first match wins. # --delete-excluded ensures test files / mocks at destination are removed. @@ -336,6 +382,27 @@ step_lint() { cd "$MOBILE_ROOT" } +step_write_sync_state() { + local mobile_commit mobile_branch core_commit core_branch checksum + mobile_commit=$(cd "$MOBILE_ROOT" && git rev-parse HEAD) + mobile_branch=$(cd "$MOBILE_ROOT" && git branch --show-current) + core_commit=$(cd "$CORE_PATH" && git rev-parse HEAD) + core_branch=$(cd "$CORE_PATH" && git branch --show-current) + checksum=$(compute_source_checksum "$CORE_PATH/$PERPS_DEST") + + cat > "$CORE_PATH/packages/perps-controller/.sync-state.json" < Date: Tue, 3 Mar 2026 14:18:51 -0600 Subject: [PATCH 2/6] fix: Align Bridge native Max-button gasless gating with extension (#26836) ## **Description** Align Bridge native Max-button gasless gating with extension by using Sentinel sendBundle support instead of LD isGaslessSwapEnabled. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes the Bridge native-token Max-button eligibility logic to depend on Sentinel `sendBundle` support (plus Smart Transactions/sponsored quotes), which can alter when users are allowed to max native balances on different chains. > > **Overview** > Aligns Bridge native-token Max-button rendering with extension by replacing the `selectIsGaslessSwapEnabled` feature-flag check with a Sentinel-based `useIsSendBundleSupported` check, using a memoized EVM-only `chainId` (non-EVM passes `undefined`). > > Updates and heavily simplifies `useShouldRenderMaxOption` tests to mock `useIsSendBundleSupported`/`isNonEvmChainId`/`formatChainIdToHex`, and to validate the new native-token truth table and chain-id wiring. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit be560bf31f9047fbe0c9d3288a43201d4947d83b. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../hooks/useShouldRenderMaxOption/index.ts | 15 +- .../useShouldRenderMaxOption.test.ts | 663 ++++-------------- 2 files changed, 150 insertions(+), 528 deletions(-) diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts index cab9c1ef187..fe774135b87 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/index.ts @@ -1,7 +1,7 @@ +import { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { selectShouldUseSmartTransaction } from '../../../../../selectors/smartTransactionsController'; import { RootState } from '../../../../../reducers'; -import { selectIsGaslessSwapEnabled } from '../../../../../core/redux/slices/bridge'; import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; import { @@ -10,14 +10,23 @@ import { isNonEvmChainId, } from '@metamask/bridge-controller'; import { BigNumber } from 'bignumber.js'; +import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; export const useShouldRenderMaxOption = ( token?: BridgeToken, displayBalance?: string, isQuoteSponsored = false, ) => { - const isGaslessSwapEnabled = useSelector((state: RootState) => - token?.chainId ? selectIsGaslessSwapEnabled(state, token.chainId) : false, + const evmChainId = useMemo(() => { + if (!token?.chainId || isNonEvmChainId(token.chainId)) { + return undefined; + } + return formatChainIdToHex(token.chainId); + }, [token?.chainId]); + const isSendBundleSupported = useIsSendBundleSupported(evmChainId); + const isGaslessSwapEnabled = useMemo( + () => Boolean(isSendBundleSupported), + [isSendBundleSupported], ); const stxEnabled = useSelector((state: RootState) => token?.chainId && !isNonEvmChainId(token.chainId) diff --git a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts index 68d0c3cd747..04fd28b2ad4 100644 --- a/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts +++ b/app/components/UI/Bridge/hooks/useShouldRenderMaxOption/useShouldRenderMaxOption.test.ts @@ -1,12 +1,16 @@ import { renderHook } from '@testing-library/react-hooks'; -import { useShouldRenderMaxOption } from '.'; -import { BridgeToken } from '../../types'; import { CHAIN_IDS } from '@metamask/transaction-controller'; +import { + formatChainIdToHex, + isNativeAddress, + isNonEvmChainId, +} from '@metamask/bridge-controller'; import { useSelector } from 'react-redux'; +import { useShouldRenderMaxOption } from '.'; +import { BridgeToken } from '../../types'; import { useTokenAddress } from '../useTokenAddress'; -import { isNativeAddress } from '@metamask/bridge-controller'; +import { useIsSendBundleSupported } from '../useIsSendBundleSupported'; -// Mock dependencies jest.mock('react-redux', () => ({ useSelector: jest.fn(), })); @@ -15,580 +19,189 @@ jest.mock('../useTokenAddress', () => ({ useTokenAddress: jest.fn(), })); -jest.mock('@metamask/bridge-controller', () => ({ - isNativeAddress: jest.fn(), +jest.mock('../useIsSendBundleSupported', () => ({ + useIsSendBundleSupported: jest.fn(), })); -jest.mock('../../../../../core/redux/slices/bridge', () => ({ - selectIsGaslessSwapEnabled: jest.fn(), -})); - -jest.mock('../../../../../selectors/smartTransactionsController', () => ({ - selectShouldUseSmartTransaction: jest.fn(), -})); +jest.mock('@metamask/bridge-controller', () => { + const actual = jest.requireActual('@metamask/bridge-controller'); + return { + ...actual, + formatChainIdToHex: jest.fn(), + isNativeAddress: jest.fn(), + isNonEvmChainId: jest.fn(), + }; +}); const mockUseSelector = useSelector as jest.MockedFunction; const mockUseTokenAddress = useTokenAddress as jest.MockedFunction< typeof useTokenAddress >; +const mockUseIsSendBundleSupported = + useIsSendBundleSupported as jest.MockedFunction< + typeof useIsSendBundleSupported + >; +const mockFormatChainIdToHex = formatChainIdToHex as jest.MockedFunction< + typeof formatChainIdToHex +>; const mockIsNativeAddress = isNativeAddress as jest.MockedFunction< typeof isNativeAddress >; +const mockIsNonEvmChainId = isNonEvmChainId as jest.MockedFunction< + typeof isNonEvmChainId +>; -/** - * IMPORTANT: useSelector call order in the hook: - * 1. First call: isGaslessSwapEnabled (line 12 in hook) - * 2. Second call: stxEnabled (line 15 in hook) - */ -describe('useShouldRenderMaxOption', () => { - const mockToken: BridgeToken = { - address: '0x1234567890123456789012345678901234567890', - symbol: 'TEST', - decimals: 18, - chainId: CHAIN_IDS.MAINNET, - }; +const mockToken: BridgeToken = { + address: '0x1234567890123456789012345678901234567890', + symbol: 'TEST', + decimals: 18, + chainId: CHAIN_IDS.MAINNET, +}; - const nativeToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'ETH', - decimals: 18, - chainId: CHAIN_IDS.MAINNET, - }; +const nativeToken: BridgeToken = { + address: '0x0000000000000000000000000000000000000000', + symbol: 'ETH', + decimals: 18, + chainId: CHAIN_IDS.MAINNET, +}; +const setSelectorValues = ({ stxEnabled = true }: { stxEnabled?: boolean }) => { + mockUseSelector.mockImplementation(() => stxEnabled); +}; + +describe('useShouldRenderMaxOption', () => { beforeEach(() => { jest.clearAllMocks(); + + setSelectorValues({ stxEnabled: true }); mockUseTokenAddress.mockReturnValue(mockToken.address); + mockUseIsSendBundleSupported.mockReturnValue(false); + mockFormatChainIdToHex.mockImplementation( + (chainId) => chainId as `0x${string}`, + ); mockIsNativeAddress.mockReturnValue(false); - // Default: isGaslessSwapEnabled = false, stxEnabled = true - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); + mockIsNonEvmChainId.mockReturnValue(false); }); - describe('Zero balance scenarios', () => { - it('returns false when displayBalance is undefined', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, undefined, false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is "0"', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is "0.0"', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.0', false), - ); + it('returns false when token is undefined', () => { + const { result } = renderHook(() => + useShouldRenderMaxOption(undefined, '10'), + ); - expect(result.current).toBe(false); - }); - - it('returns false when displayBalance is empty string', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '', false), - ); - - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Non-native token scenarios', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - }); - - it('returns true for non-native token with balance regardless of gasless', () => { - mockUseSelector.mockImplementation(() => false); // Both selectors false - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '100.5', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with balance regardless of stxEnabled', () => { - mockUseSelector.mockImplementation(() => false); // stxEnabled = false - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '50', false), - ); + it('returns false when display balance is zero', () => { + const { result } = renderHook(() => + useShouldRenderMaxOption(mockToken, '0'), + ); - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with balance regardless of isQuoteSponsored', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '25.75', true), - ); - - expect(result.current).toBe(true); - }); - - it('returns true for non-native token with very small balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns false for non-native token with zero balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0', false), - ); - - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Native token scenarios', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - }); - - it('returns false when native token has zero balance', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '0', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when native token has zero balance even with all conditions favorable', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - mockUseSelector.mockReturnValue(true); // stxEnabled=true, gasless=true + it('returns true for non-native token with positive balance', () => { + mockIsNativeAddress.mockReturnValue(false); - const { result } = renderHook( - () => useShouldRenderMaxOption(nativeToken, '0', true), // sponsored=true - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(mockToken, '10'), + ); - // Zero balance always returns false - expect(result.current).toBe(false); - }); + expect(result.current).toBe(true); }); - describe('Edge cases', () => { - it('returns false when token is undefined', () => { - mockUseTokenAddress.mockReturnValue(undefined); - - const { result } = renderHook(() => - useShouldRenderMaxOption(undefined, '100', false), - ); - - expect(result.current).toBe(false); - }); - - it('returns false when token is undefined but has balance', () => { - mockUseTokenAddress.mockReturnValue(undefined); - mockIsNativeAddress.mockReturnValue(false); - - const { result } = renderHook(() => - useShouldRenderMaxOption(undefined, '500', false), - ); + it('returns true for native token when stx and sendBundle are enabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(true); - expect(result.current).toBe(false); - }); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - it('handles large balance values correctly for non-native tokens', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '1000000.123456789', false), - ); - - expect(result.current).toBe(true); - }); - - it('handles very small but non-zero balance for non-native tokens', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.000000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('correctly identifies native token without chainId in token object', () => { - const tokenWithoutChainId = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'ETH', - decimals: 18, - } as BridgeToken; + expect(result.current).toBe(true); + }); - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(tokenWithoutChainId.address); - mockUseSelector.mockReturnValue(false); // stxEnabled = false, isGaslessSwapEnabled = false + it('returns false for native token when sendBundle is disabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(false); - const { result } = renderHook(() => - useShouldRenderMaxOption(tokenWithoutChainId, '100', false), - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - // Should return false (native + no gasless + no sponsored + no stx) - expect(result.current).toBe(false); - }); + expect(result.current).toBe(false); }); - describe('Hook parameter validation', () => { - it('uses useTokenAddress hook to get token address', () => { - const customToken = { - ...mockToken, - address: '0xabcdef1234567890abcdef1234567890abcdef12', - }; - mockUseTokenAddress.mockReturnValue(customToken.address); + it('returns false for native token when stx is disabled even if sendBundle is enabled', () => { + setSelectorValues({ stxEnabled: false }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(true); - renderHook(() => useShouldRenderMaxOption(customToken, '100', false)); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25'), + ); - expect(mockUseTokenAddress).toHaveBeenCalledWith(customToken); - }); + expect(result.current).toBe(false); + }); - it('checks if token address is native using isNativeAddress', () => { - const tokenAddress = '0x1234567890123456789012345678901234567890'; - mockUseTokenAddress.mockReturnValue(tokenAddress); - mockIsNativeAddress.mockReturnValue(false); + it('returns true for sponsored native quote when stx is enabled', () => { + setSelectorValues({ stxEnabled: true }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockUseIsSendBundleSupported.mockReturnValue(false); - renderHook(() => useShouldRenderMaxOption(mockToken, '100', false)); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25', true), + ); - expect(mockIsNativeAddress).toHaveBeenCalledWith(tokenAddress); - }); + expect(result.current).toBe(true); + }); - it('calls selectIsGaslessSwapEnabled with correct chainId', () => { - const tokenWithChainId = { - ...mockToken, - chainId: '0xa' as `0x${string}`, // Optimism - }; - mockUseSelector.mockReturnValue(true); + it('returns false for sponsored native quote when stx is disabled', () => { + setSelectorValues({ stxEnabled: false }); + mockUseTokenAddress.mockReturnValue(nativeToken.address); + mockIsNativeAddress.mockReturnValue(true); - renderHook(() => - useShouldRenderMaxOption(tokenWithChainId, '100', false), - ); + const { result } = renderHook(() => + useShouldRenderMaxOption(nativeToken, '1.25', true), + ); - // Verify useSelector was called (it uses selectIsGaslessSwapEnabled) - expect(mockUseSelector).toHaveBeenCalled(); - }); + expect(result.current).toBe(false); }); - describe('Default parameter values', () => { - it('uses false as default for isQuoteSponsored when not provided', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - // Call without isQuoteSponsored parameter - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '100'), - ); - - // Should return false (native + stx=true + gasless=false + sponsored=false) - expect(result.current).toBe(false); - }); - }); + it('passes formatted EVM chain id to sendBundle hook', () => { + const chainId = '0xa' as `0x${string}`; + const formattedChainId = '0xa' as `0x${string}`; + const token = { ...nativeToken, chainId }; + mockUseTokenAddress.mockReturnValue(token.address); + mockIsNativeAddress.mockReturnValue(true); + mockFormatChainIdToHex.mockReturnValue(formattedChainId); - describe('Integration scenarios', () => { - it('returns correct value for typical non-native ERC20 token', () => { - const usdcToken: BridgeToken = { - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - symbol: 'USDC', - decimals: 6, - chainId: CHAIN_IDS.MAINNET, - }; - - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(usdcToken.address); - mockUseSelector.mockReturnValue(false); // Everything disabled - - const { result } = renderHook(() => - useShouldRenderMaxOption(usdcToken, '1000', false), - ); - - // Non-native tokens always show max (even with everything disabled) - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH with gasless swap enabled', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - mockUseSelector.mockReturnValue(true); // stxEnabled=true, gasless=true - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '5.5', false), - ); - - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH with sponsored quote', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '2.25', true), - ); - - expect(result.current).toBe(true); - }); - - it('returns correct value for native ETH without gasless or sponsored but stx enabled', () => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = true - return callCount === 2; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '10', false), - ); - - // Should be false (native + stx=true + gasless=false + sponsored=false) - expect(result.current).toBe(false); - }); - }); + renderHook(() => useShouldRenderMaxOption(token, '1.25')); - describe('Boundary conditions', () => { - it('handles balance exactly equal to zero', () => { - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.00000', false), - ); - - expect(result.current).toBe(false); - }); - - it('handles extremely small but non-zero balance', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption(mockToken, '0.00000001', false), - ); - - expect(result.current).toBe(true); - }); - - it('handles extremely large balance', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption( - mockToken, - '999999999999999999999999999.999999', - false, - ), - ); - - expect(result.current).toBe(true); - }); - - it('handles balance with many decimal places', () => { - mockIsNativeAddress.mockReturnValue(false); - mockUseTokenAddress.mockReturnValue(mockToken.address); - - const { result } = renderHook(() => - useShouldRenderMaxOption( - mockToken, - '123.456789012345678901234567', - false, - ), - ); - - expect(result.current).toBe(true); - }); + expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(formattedChainId); }); - describe('Truth table - Native token with all combinations', () => { - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - mockUseTokenAddress.mockReturnValue(nativeToken.address); - }); - - const truthTable = [ - { stxEnabled: true, gasless: true, sponsored: false, expected: true }, - { stxEnabled: true, gasless: false, sponsored: true, expected: true }, - { stxEnabled: true, gasless: true, sponsored: true, expected: true }, - { stxEnabled: true, gasless: false, sponsored: false, expected: false }, - { stxEnabled: false, gasless: true, sponsored: false, expected: false }, - { stxEnabled: false, gasless: false, sponsored: true, expected: false }, - { stxEnabled: false, gasless: true, sponsored: true, expected: false }, - { stxEnabled: false, gasless: false, sponsored: false, expected: false }, - ]; - - truthTable.forEach(({ stxEnabled, gasless, sponsored, expected }) => { - it(`stxEnabled=${stxEnabled}, gasless=${gasless}, sponsored=${sponsored} → returns ${expected}`, () => { - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled (line 12 in hook) - // Second call: stxEnabled (line 15 in hook) - if (callCount === 1) { - return gasless; - } - return stxEnabled; - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(nativeToken, '100', sponsored), - ); - - expect(result.current).toBe(expected); - }); - }); - }); - - describe('Non-EVM native token scenarios', () => { + it('passes undefined chain id to sendBundle hook for non-EVM token', () => { const solanaToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'SOL', - decimals: 9, - chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', // Solana mainnet CAIP-2 + ...nativeToken, + chainId: + 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as `${string}:${string}`, }; + mockUseTokenAddress.mockReturnValue(solanaToken.address); + mockIsNativeAddress.mockReturnValue(true); + mockIsNonEvmChainId.mockReturnValue(true); + setSelectorValues({ stxEnabled: false }); - const bitcoinToken: BridgeToken = { - address: '0x0000000000000000000000000000000000000000', - symbol: 'BTC', - decimals: 8, - chainId: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin mainnet CAIP-2 - }; + const { result } = renderHook(() => + useShouldRenderMaxOption(solanaToken, '3'), + ); - beforeEach(() => { - mockIsNativeAddress.mockReturnValue(true); - }); - - it('returns false for Solana native token even with gasless enabled', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(solanaToken, '100', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Solana native token even with sponsored quote', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - mockUseSelector.mockImplementation( - () => - // First call: isGaslessSwapEnabled = false - // Second call: stxEnabled = false (non-EVM chain) - false, - ); - - const { result } = renderHook( - () => useShouldRenderMaxOption(solanaToken, '50', true), // sponsored = true - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Solana native token with both gasless and sponsored enabled', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook( - () => useShouldRenderMaxOption(solanaToken, '25.5', true), // sponsored = true - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Bitcoin native token with gasless enabled', () => { - mockUseTokenAddress.mockReturnValue(bitcoinToken.address); - let callCount = 0; - mockUseSelector.mockImplementation(() => { - callCount++; - // First call: isGaslessSwapEnabled = true - // Second call: stxEnabled = false (non-EVM chain) - if (callCount === 1) { - return true; // gasless enabled - } - return false; // stxEnabled is false for non-EVM - }); - - const { result } = renderHook(() => - useShouldRenderMaxOption(bitcoinToken, '1.5', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for Bitcoin native token without any flags enabled', () => { - mockUseTokenAddress.mockReturnValue(bitcoinToken.address); - mockUseSelector.mockReturnValue(false); // All flags disabled - - const { result } = renderHook(() => - useShouldRenderMaxOption(bitcoinToken, '0.5', false), - ); - - // Should return false because stxEnabled is false for non-EVM chains - expect(result.current).toBe(false); - }); - - it('returns false for non-EVM native token with zero balance', () => { - mockUseTokenAddress.mockReturnValue(solanaToken.address); - mockUseSelector.mockReturnValue(false); - - const { result } = renderHook(() => - useShouldRenderMaxOption(solanaToken, '0', false), - ); - - // Should return false due to zero balance (checked before non-EVM logic) - expect(result.current).toBe(false); - }); + expect(mockUseIsSendBundleSupported).toHaveBeenCalledWith(undefined); + expect(result.current).toBe(false); }); }); From 9c138df6a2da97c6f46211cdc3728257fa9ac7d7 Mon Sep 17 00:00:00 2001 From: Matthew Grainger <46547583+Matt561@users.noreply.github.com> Date: Tue, 3 Mar 2026 15:20:49 -0500 Subject: [PATCH 3/6] fix: MUSD-282 Add useTokensBuyability hook (#25539) ## **Description** This PR introduces `useTokensBuyability(tokens)` to evaluate buyability for multiple tokens in a single hook call, and keeps `useTokenBuyability(token)` as a backward-compatible convenience wrapper over the batch hook. `useTokensBuyability` returns buyability as `buyabilityByTokenKey` (keyed by normalized chain + token address). Motivation: reduce redundant legacy ramp token fetches when checking multiple tokens in the same render path. Previously, calling `useTokenBuyability` N times could trigger repeated legacy useRampTokens mount-driven fetch behavior. This change also preserves V2 efficiency by passing `fetchOnMount: !isV2Enabled`, so the legacy fetch is suppressed when unified buy V2 is enabled and controller-backed tokens are used. ## **Changelog** CHANGELOG entry: refactored Ramp buyability to add batched useTokensBuyability with keyed results and keep useTokenBuyability as a backward-compatible wrapper, reducing redundant legacy token-cache fetches for multi-token checks. ## **Related issues** Fixes: [MUSD-282: mUSD Asset List CTA is spamming the Ramps Tokens API endpoint](https://consensyssoftware.atlassian.net/browse/MUSD-282) ## **Manual testing steps** ```gherkin Feature: Batched Ramp token buyability checks Scenario: user views mUSD eligibility Given user is on a screen that evaluates multiple token buyability states (e.g. Token List) Then the app checks buyability of multiple tokens in one hook call ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** When `MM_RAMPS_UNIFIED_BUY_V2_ENABLED="true"` `useMusdRampAvailability` leverages ramp token cache. ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes Ramp buyability evaluation and when the legacy token-cache API is fetched; regressions could hide/show buy CTAs incorrectly or delay buyability data if the new fetch gating is misused. > > **Overview** > Introduces `useTokensBuyability(tokens)` and supporting helpers (e.g., `getTokenBuyabilityKey`) to compute Ramp buyability for multiple tokens in one hook call, while keeping `useTokenBuyability(token)` as a backward-compatible wrapper. > > Adds a `fetchOnMount` option to `useRampTokens` and uses it from buyability logic to **skip legacy token-cache fetching when Unified Ramps V2 is enabled**, reducing repeated API calls. > > Refactors `useMusdRampAvailability` to derive per-chain mUSD buyability via `useTokensBuyability` (using mUSD token addresses per chain), and updates/expands tests across Ramp and Earn hooks to cover the new batching and fetch gating behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 437bc20f8c6e2d5cd36e85bb9a0240527569b490. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Earn/hooks/useMusdCtaVisibility.test.ts | 19 ++ .../hooks/useMusdRampAvailability.test.ts | 307 +++++++----------- .../UI/Earn/hooks/useMusdRampAvailability.ts | 70 ++-- .../UI/Ramp/hooks/useRampTokens.test.ts | 23 ++ app/components/UI/Ramp/hooks/useRampTokens.ts | 14 +- .../UI/Ramp/hooks/useTokenBuyability.test.ts | 306 +++++++++++++++-- .../UI/Ramp/hooks/useTokenBuyability.ts | 129 ++++++-- 7 files changed, 601 insertions(+), 267 deletions(-) diff --git a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts index 68c0e577231..445ae7ec889 100644 --- a/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts +++ b/app/components/UI/Earn/hooks/useMusdCtaVisibility.test.ts @@ -11,6 +11,8 @@ import { useMusdConversionEligibility } from './useMusdConversionEligibility'; import { useCurrentNetworkInfo } from '../../../hooks/useCurrentNetworkInfo'; import { useNetworksByCustomNamespace } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; +import useRampsTokens from '../../Ramp/hooks/useRampsTokens'; +import useRampsUnifiedV2Enabled from '../../Ramp/hooks/useRampsUnifiedV2Enabled'; import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; import { createMockToken } from '../../Stake/testUtils'; import { @@ -31,6 +33,8 @@ jest.mock('./useMusdConversionEligibility'); jest.mock('../../../hooks/useCurrentNetworkInfo'); jest.mock('../../../hooks/useNetworksByNamespace/useNetworksByNamespace'); jest.mock('../../Ramp/hooks/useRampTokens'); +jest.mock('../../Ramp/hooks/useRampsTokens'); +jest.mock('../../Ramp/hooks/useRampsUnifiedV2Enabled'); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), @@ -61,6 +65,13 @@ const mockUseNetworksByCustomNamespace = const mockUseRampTokens = useRampTokens as jest.MockedFunction< typeof useRampTokens >; +const mockUseRampsTokens = useRampsTokens as jest.MockedFunction< + typeof useRampsTokens +>; +const mockUseRampsUnifiedV2Enabled = + useRampsUnifiedV2Enabled as jest.MockedFunction< + typeof useRampsUnifiedV2Enabled + >; const mockUseMusdConversionTokens = useMusdConversionTokens as jest.MockedFunction< typeof useMusdConversionTokens @@ -195,6 +206,14 @@ describe('useMusdCtaVisibility', () => { mockUseNetworksByCustomNamespace.mockReturnValue( defaultNetworksByNamespace, ); + mockUseRampsUnifiedV2Enabled.mockReturnValue(false); + mockUseRampsTokens.mockReturnValue({ + tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), + isLoading: false, + error: null, + }); mockUseRampTokens.mockReturnValue(defaultRampTokens); mockUseMusdConversionTokens.mockReturnValue({ tokens: [], diff --git a/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts b/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts index 27641a1dfe2..a0ad78b53c6 100644 --- a/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts +++ b/app/components/UI/Earn/hooks/useMusdRampAvailability.test.ts @@ -2,233 +2,174 @@ import { renderHook } from '@testing-library/react-hooks'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { Hex } from '@metamask/utils'; import { useMusdRampAvailability } from './useMusdRampAvailability'; -import { useRampTokens, RampsToken } from '../../Ramp/hooks/useRampTokens'; -import { MUSD_TOKEN_ASSET_ID_BY_CHAIN } from '../constants/musd'; - -jest.mock('../../Ramp/hooks/useRampTokens'); +import { + MUSD_BUYABLE_CHAIN_IDS, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS_BY_CHAIN, +} from '../constants/musd'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../../Ramp/hooks/useTokenBuyability'; +import { TokenI } from '../../Tokens/types'; + +jest.mock('../../Ramp/hooks/useTokenBuyability', () => { + const actual = jest.requireActual('../../Ramp/hooks/useTokenBuyability'); + return { + ...actual, + useTokensBuyability: jest.fn(), + }; +}); -const mockUseRampTokens = useRampTokens as jest.MockedFunction< - typeof useRampTokens +const mockUseTokensBuyability = useTokensBuyability as jest.MockedFunction< + typeof useTokensBuyability >; describe('useMusdRampAvailability', () => { - const createMusdRampToken = ( - chainId: Hex, - tokenSupported = true, - ): RampsToken => { - const assetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId].toLowerCase(); - const caipChainId = assetId.split('/')[0] as `${string}:${string}`; - return { - assetId: assetId as `${string}:${string}/${string}:${string}`, - symbol: 'MUSD', - chainId: caipChainId, - tokenSupported, - name: 'MetaMask USD', - decimals: 6, - iconUrl: 'https://example.com/musd.png', - }; - }; + const getMusdToken = (chainId: Hex): TokenI => ({ + address: MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId], + chainId, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + image: '', + logo: undefined, + balance: '0', + isETH: false, + isNative: false, + }); - const defaultRampTokens = { - topTokens: null, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex), - ], - isLoading: false, - error: null, + const MAINNET_MUSD_KEY = getTokenBuyabilityKey( + getMusdToken(CHAIN_IDS.MAINNET as Hex), + ); + const LINEA_MUSD_KEY = getTokenBuyabilityKey( + getMusdToken(CHAIN_IDS.LINEA_MAINNET as Hex), + ); + + const setBuyability = ( + buyabilityByTokenKey: Record = {}, + ) => { + mockUseTokensBuyability.mockReturnValue({ + buyabilityByTokenKey, + isLoading: false, + }); }; beforeEach(() => { jest.clearAllMocks(); - mockUseRampTokens.mockReturnValue(defaultRampTokens); - }); - - afterEach(() => { - jest.resetAllMocks(); + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, + }); }); - describe('hook structure', () => { - it('returns object with all required properties', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current).toHaveProperty('isMusdBuyableOnChain'); - expect(result.current).toHaveProperty('isMusdBuyableOnAnyChain'); - expect(result.current).toHaveProperty('getIsMusdBuyable'); - }); + it('returns false for all mUSD buyable chains when no tokens are buyable', () => { + setBuyability({}); - it('returns getIsMusdBuyable as a function', () => { - const { result } = renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(typeof result.current.getIsMusdBuyable).toBe('function'); + MUSD_BUYABLE_CHAIN_IDS.forEach((chainId) => { + expect(result.current.isMusdBuyableOnChain[chainId]).toBe(false); }); + expect(result.current.isMusdBuyableOnAnyChain).toBe(false); }); - describe('isMusdBuyableOnChain', () => { - it('returns empty object when allTokens is null', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: null, - }); + it('marks remaining chains as not buyable when buyability results are missing', () => { + setBuyability({ [MAINNET_MUSD_KEY]: true }); - const { result } = renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(result.current.isMusdBuyableOnChain).toEqual({}); - }); + expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe(true); + expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( + false, + ); + }); - it('returns buyability status for each chain', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe(true); - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( - false, - ); + it('returns true when at least one chain has buyable mUSD', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: false, + [LINEA_MUSD_KEY]: true, }); - it('returns false for chain when token not supported', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.MAINNET]).toBe( - false, - ); - expect(result.current.isMusdBuyableOnChain[CHAIN_IDS.LINEA_MAINNET]).toBe( - false, - ); - }); - }); + const { result } = renderHook(() => useMusdRampAvailability()); - describe('isMusdBuyableOnAnyChain', () => { - it('returns true when at least one chain has buyable mUSD', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, true), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); + expect(result.current.isMusdBuyableOnAnyChain).toBe(true); + }); - expect(result.current.isMusdBuyableOnAnyChain).toBe(true); + it('returns chain-specific buyability when a single chain is selected', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: false, }); - it('returns false when no chains have buyable mUSD', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); + const { result } = renderHook(() => useMusdRampAvailability()); - const { result } = renderHook(() => useMusdRampAvailability()); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.MAINNET as Hex, + false, + ); + + expect(isMusdBuyable).toBe(true); + }); - expect(result.current.isMusdBuyableOnAnyChain).toBe(false); + it('returns false when selected chain is not buyable', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: false, }); - it('returns false when allTokens is empty', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [], - }); + const { result } = renderHook(() => useMusdRampAvailability()); - const { result } = renderHook(() => useMusdRampAvailability()); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.LINEA_MAINNET as Hex, + false, + ); - expect(result.current.isMusdBuyableOnAnyChain).toBe(false); - }); + expect(isMusdBuyable).toBe(false); }); - describe('getIsMusdBuyable', () => { - it('returns isMusdBuyableOnAnyChain when popular networks filter is active', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, false), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, true), - ], - }); + it('returns false when no chain is selected and popular networks filter is inactive', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, + }); - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable(null, true); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(isMusdBuyable).toBe(true); - }); + const isMusdBuyable = result.current.getIsMusdBuyable(null, false); - it('returns chain-specific buyability when single chain selected', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - CHAIN_IDS.MAINNET as Hex, - false, - ); - - expect(isMusdBuyable).toBe(true); - }); + expect(isMusdBuyable).toBe(false); + }); - it('returns false when selected chain not buyable', () => { - mockUseRampTokens.mockReturnValue({ - ...defaultRampTokens, - allTokens: [ - createMusdRampToken(CHAIN_IDS.MAINNET as Hex, true), - createMusdRampToken(CHAIN_IDS.LINEA_MAINNET as Hex, false), - ], - }); - - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - CHAIN_IDS.LINEA_MAINNET as Hex, - false, - ); - - expect(isMusdBuyable).toBe(false); + it('returns false for an unknown chain ID', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: true, + [LINEA_MUSD_KEY]: true, }); - it('returns false when no chain selected and popular networks filter is inactive', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable(null, false); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(isMusdBuyable).toBe(false); - }); + const isMusdBuyable = result.current.getIsMusdBuyable( + '0x999' as Hex, + false, + ); - it('returns false for unknown chain ID', () => { - const { result } = renderHook(() => useMusdRampAvailability()); - const isMusdBuyable = result.current.getIsMusdBuyable( - '0x999' as Hex, - false, - ); + expect(isMusdBuyable).toBe(false); + }); - expect(isMusdBuyable).toBe(false); + it('uses aggregate buyability when popular networks filter is active even with selected chain', () => { + setBuyability({ + [MAINNET_MUSD_KEY]: false, + [LINEA_MUSD_KEY]: true, }); - }); - describe('integration with useRampTokens', () => { - it('calls useRampTokens hook', () => { - renderHook(() => useMusdRampAvailability()); + const { result } = renderHook(() => useMusdRampAvailability()); - expect(mockUseRampTokens).toHaveBeenCalled(); - }); + const isMusdBuyable = result.current.getIsMusdBuyable( + CHAIN_IDS.MAINNET as Hex, + true, + ); + + expect(isMusdBuyable).toBe(true); }); }); diff --git a/app/components/UI/Earn/hooks/useMusdRampAvailability.ts b/app/components/UI/Earn/hooks/useMusdRampAvailability.ts index 2eeb16eebe8..93bca09ad05 100644 --- a/app/components/UI/Earn/hooks/useMusdRampAvailability.ts +++ b/app/components/UI/Earn/hooks/useMusdRampAvailability.ts @@ -1,11 +1,15 @@ import { useCallback, useMemo } from 'react'; import { Hex } from '@metamask/utils'; import { + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS_BY_CHAIN, MUSD_BUYABLE_CHAIN_IDS, - MUSD_TOKEN_ASSET_ID_BY_CHAIN, } from '../constants/musd'; -import { useRampTokens } from '../../Ramp/hooks/useRampTokens'; -import { toLowerCaseEquals } from '../../../../util/general'; +import { TokenI } from '../../Tokens/types'; +import { + getTokenBuyabilityKey, + useTokensBuyability, +} from '../../Ramp/hooks/useTokenBuyability'; export interface MusdRampAvailability { isMusdBuyableOnChain: Record; @@ -26,34 +30,56 @@ export interface MusdRampAvailability { * @returns {MusdRampAvailability} Ramp availability state and helpers */ export const useMusdRampAvailability = (): MusdRampAvailability => { - const { allTokens } = useRampTokens(); + const musdTokensByChain = useMemo( + () => + MUSD_BUYABLE_CHAIN_IDS.map((chainId) => { + const address = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!address) { + return null; + } + + const token: TokenI = { + address, + chainId, + symbol: MUSD_TOKEN.symbol, + name: MUSD_TOKEN.name, + decimals: MUSD_TOKEN.decimals, + image: '', + logo: undefined, + balance: '0', + isETH: false, + isNative: false, + }; + return token; + }), + [], + ); + + const musdTokens = useMemo( + () => musdTokensByChain.filter((token): token is TokenI => token !== null), + [musdTokensByChain], + ); + + const { buyabilityByTokenKey } = useTokensBuyability(musdTokens); // Check if mUSD is buyable on a specific chain based on ramp availability const isMusdBuyableOnChain = useMemo(() => { - if (!allTokens) { - return {}; - } - const buyableByChain: Record = {}; - MUSD_BUYABLE_CHAIN_IDS.forEach((chainId) => { - const musdAssetId = MUSD_TOKEN_ASSET_ID_BY_CHAIN[chainId]; - if (!musdAssetId) { - buyableByChain[chainId] = false; - return; - } - - const musdToken = allTokens.find( - (token) => - toLowerCaseEquals(token.assetId, musdAssetId) && - token.tokenSupported === true, - ); + buyableByChain[chainId] = false; + }); - buyableByChain[chainId] = Boolean(musdToken); + musdTokens.forEach((token) => { + if (token.chainId) { + const tokenKey = getTokenBuyabilityKey(token); + buyableByChain[token.chainId as Hex] = Boolean( + buyabilityByTokenKey[tokenKey], + ); + } }); return buyableByChain; - }, [allTokens]); + }, [buyabilityByTokenKey, musdTokens]); // Check if mUSD is buyable on any chain (for "all networks" view) const isMusdBuyableOnAnyChain = useMemo( diff --git a/app/components/UI/Ramp/hooks/useRampTokens.test.ts b/app/components/UI/Ramp/hooks/useRampTokens.test.ts index 17f3f346f18..f0f0b0a6bca 100644 --- a/app/components/UI/Ramp/hooks/useRampTokens.test.ts +++ b/app/components/UI/Ramp/hooks/useRampTokens.test.ts @@ -182,6 +182,29 @@ describe('useRampTokens', () => { }); }); + describe('fetchOnMount option', () => { + it('skips fetching tokens when fetchOnMount is false', () => { + const mockResponse = createMockResponse( + [createMockToken({ symbol: 'ETH' })], + [createMockToken({ symbol: 'ETH' })], + ); + mockHandleFetch.mockResolvedValueOnce(mockResponse); + + const { result } = renderHookWithProvider( + () => useRampTokens({ fetchOnMount: false }), + { + state: createMockState(UnifiedRampRoutingType.AGGREGATOR, 'us-ca'), + }, + ); + + expect(result.current.topTokens).toBeNull(); + expect(result.current.allTokens).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(mockHandleFetch).not.toHaveBeenCalled(); + }); + }); + describe('environment-based URL selection', () => { it('uses production URL for production environment', async () => { process.env.METAMASK_ENVIRONMENT = 'production'; diff --git a/app/components/UI/Ramp/hooks/useRampTokens.ts b/app/components/UI/Ramp/hooks/useRampTokens.ts index 7249f821898..2c11858411a 100644 --- a/app/components/UI/Ramp/hooks/useRampTokens.ts +++ b/app/components/UI/Ramp/hooks/useRampTokens.ts @@ -48,12 +48,18 @@ export interface UseRampTokensResult { error: Error | null; } +interface UseRampTokensOptions { + fetchOnMount: boolean; +} + /** * Hook to fetch available tokens for ramp flows based on user region and routing decision. * * @returns An object containing top tokens, all tokens, loading state, and error state */ -export function useRampTokens(): UseRampTokensResult { +export function useRampTokens( + { fetchOnMount }: UseRampTokensOptions = { fetchOnMount: true }, +): UseRampTokensResult { const [rawTopTokens, setRawTopTokens] = useState(null); const [rawAllTokens, setRawAllTokens] = useState(null); const [isLoading, setIsLoading] = useState(false); @@ -126,8 +132,10 @@ export function useRampTokens(): UseRampTokensResult { }, [detectedGeolocation, rampRoutingDecision]); useEffect(() => { - fetchTokens(); - }, [fetchTokens]); + if (fetchOnMount) { + fetchTokens(); + } + }, [fetchTokens, fetchOnMount]); // Filter tokens to only include those for networks the user has added const topTokens = useMemo(() => { diff --git a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts index 83e22bf563d..c0a8bfd706f 100644 --- a/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts +++ b/app/components/UI/Ramp/hooks/useTokenBuyability.test.ts @@ -1,5 +1,6 @@ import { renderHook } from '@testing-library/react-native'; -import { useTokenBuyability } from './useTokenBuyability'; +// eslint-disable-next-line import/no-namespace +import * as tokenBuyabilityModule from './useTokenBuyability'; import { useRampTokens, UseRampTokensResult, @@ -8,6 +9,7 @@ import { import { useRampsTokens } from './useRampsTokens'; import useRampsUnifiedV2Enabled from './useRampsUnifiedV2Enabled'; import { TokenI } from '../../Tokens/types'; +import parseRampIntent from '../utils/parseRampIntent'; jest.mock('./useRampTokens', () => ({ useRampTokens: jest.fn(), @@ -18,33 +20,40 @@ jest.mock('./useRampsTokens', () => ({ })); jest.mock('./useRampsUnifiedV2Enabled', () => jest.fn()); +jest.mock('../utils/parseRampIntent', () => jest.fn()); const mockUseRampTokens = jest.mocked(useRampTokens); const mockUseRampsTokens = jest.mocked(useRampsTokens); const mockUseRampsUnifiedV2Enabled = jest.mocked(useRampsUnifiedV2Enabled); +const mockParseRampIntent = jest.mocked(parseRampIntent); describe('useTokenBuyability', () => { - const getMockToken = (overrides: Partial = {}): TokenI => - ({ - address: '0x6b175474e89094c44da98b954eedeac495271d0f', - symbol: 'DAI', - name: 'Dai Stablecoin', - decimals: 18, - chainId: '0x1', - ...overrides, - }) as TokenI; - - const getMockRampToken = (overrides: Record = {}) => - ({ - name: 'Mock Token', - symbol: 'MOCK', - decimals: 18, - iconUrl: 'https://example.com/icon.png', - assetId: '', - chainId: '', - tokenSupported: false, - ...overrides, - }) as unknown as RampsToken; + const getMockToken = (overrides: Partial = {}): TokenI => ({ + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + symbol: 'DAI', + name: 'Dai Stablecoin', + decimals: 18, + chainId: '0x1', + image: '', + logo: undefined, + balance: '0', + isETH: false, + isNative: false, + ...overrides, + }); + + const getMockRampToken = ( + overrides: Partial = {}, + ): RampsToken => ({ + name: 'Mock Token', + symbol: 'MOCK', + decimals: 18, + iconUrl: 'https://example.com/icon.png', + assetId: 'eip155:1/erc20:0x0000000000000000000000000000000000000000', + chainId: 'eip155:1', + tokenSupported: false, + ...overrides, + }); const setupV1Mocks = (): void => { mockUseRampsUnifiedV2Enabled.mockReturnValue(false); @@ -67,9 +76,180 @@ describe('useTokenBuyability', () => { beforeEach(() => { jest.clearAllMocks(); + mockParseRampIntent.mockReturnValue({ + assetId: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + }); setupV1Mocks(); }); + describe('useTokensBuyability', () => { + it('maps buyability for multiple tokens by token key', () => { + const firstToken = getMockToken({ + address: '0x6b175474e89094c44da98b954eedeac495271d0f', + }); + const secondToken = getMockToken({ + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + }); + + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + tokenSupported: true, + }), + getMockRampToken({ + assetId: + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + tokenSupported: false, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + mockParseRampIntent.mockImplementation(({ address }) => ({ + assetId: `eip155:1/erc20:${address?.toLowerCase()}`, + })); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([firstToken, secondToken]), + ); + + expect(result.current.buyabilityByTokenKey).toEqual({ + [tokenBuyabilityModule.getTokenBuyabilityKey(firstToken)]: true, + [tokenBuyabilityModule.getTokenBuyabilityKey(secondToken)]: false, + }); + }); + + it('returns loading from controller tokens when v2 is enabled', () => { + setupV2Mocks(); + mockUseRampsTokens.mockReturnValue({ + tokens: null, + selectedToken: null, + setSelectedToken: jest.fn(), + isLoading: true, + error: null, + }); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([getMockToken()]), + ); + + expect(result.current.isLoading).toBe(true); + expect(mockUseRampTokens).toHaveBeenCalledWith({ fetchOnMount: false }); + }); + + it('returns loading from legacy tokens when v2 is disabled', () => { + setupV1Mocks(); + mockUseRampTokens.mockReturnValue({ + allTokens: null, + isLoading: true, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([getMockToken()]), + ); + + expect(result.current.isLoading).toBe(true); + expect(mockUseRampTokens).toHaveBeenCalledWith({ fetchOnMount: true }); + }); + + it('does not call parseRampIntent when token address is already a CAIP asset type', () => { + const caipToken = getMockToken({ + address: 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: 'eip155:1', + }); + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + tokenSupported: true, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([caipToken]), + ); + + expect(result.current.buyabilityByTokenKey).toEqual({ + [tokenBuyabilityModule.getTokenBuyabilityKey(caipToken)]: true, + }); + expect(mockParseRampIntent).not.toHaveBeenCalled(); + }); + + it('returns false when mockParseRampIntent parsing throws', () => { + mockParseRampIntent.mockImplementation(() => { + throw new Error('parse failed'); + }); + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + tokenSupported: true, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([getMockToken()]), + ); + + expect(result.current.buyabilityByTokenKey).toEqual({ + [tokenBuyabilityModule.getTokenBuyabilityKey(getMockToken())]: false, + }); + }); + + it('returns false for native token when chainId differs', () => { + const nativeToken = getMockToken({ isNative: true, chainId: '0x1' }); + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: 'eip155:10/slip44:60', + chainId: 'eip155:10', + tokenSupported: true, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([nativeToken]), + ); + + expect(result.current.buyabilityByTokenKey).toEqual({ + [tokenBuyabilityModule.getTokenBuyabilityKey(nativeToken)]: false, + }); + }); + + it('returns false for native token when assetId is not slip44', () => { + const nativeToken = getMockToken({ isNative: true, chainId: '0x1' }); + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + chainId: 'eip155:1', + tokenSupported: true, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([nativeToken]), + ); + + expect(result.current.buyabilityByTokenKey).toEqual({ + [tokenBuyabilityModule.getTokenBuyabilityKey(nativeToken)]: false, + }); + }); + }); + describe('when V2 is disabled (legacy flow)', () => { const testCases = [ { @@ -87,11 +267,12 @@ describe('useTokenBuyability', () => { { testName: 'token is in the list but not supported', hookReturn: [ - { - address: '0x6b175474e89094c44da98b954eedeac495271d0f', + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', chainId: 'eip155:1', tokenSupported: false, - }, + }), ], token: getMockToken(), expectedBuyable: false, @@ -131,20 +312,37 @@ describe('useTokenBuyability', () => { isLoading: false, } as UseRampTokensResult); - const { result } = renderHook(() => useTokenBuyability(token)); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(token), + ); expect(result.current.isBuyable).toBe(expectedBuyable); expect(result.current.isLoading).toBe(false); }, ); + it('ignores ramp entries without assetId', () => { + mockUseRampTokens.mockReturnValue({ + allTokens: [getMockRampToken({ assetId: '' })], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); + + expect(result.current.isBuyable).toBe(false); + }); + it('returns isLoading: true when ramp tokens are loading', () => { mockUseRampTokens.mockReturnValue({ allTokens: null, isLoading: true, } as UseRampTokensResult); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); expect(result.current.isBuyable).toBe(false); expect(result.current.isLoading).toBe(true); @@ -165,7 +363,9 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); expect(result.current.isBuyable).toBe(false); }); @@ -190,7 +390,9 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); expect(result.current.isBuyable).toBe(true); }); @@ -213,7 +415,9 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); expect(result.current.isBuyable).toBe(false); }); @@ -227,7 +431,9 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); expect(result.current.isBuyable).toBe(false); expect(result.current.isLoading).toBe(true); @@ -257,7 +463,9 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); // V2 enabled: uses only V2 controller tokens, no legacy fallback expect(result.current.isBuyable).toBe(false); @@ -294,10 +502,42 @@ describe('useTokenBuyability', () => { error: null, }); - const { result } = renderHook(() => useTokenBuyability(getMockToken())); + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(getMockToken()), + ); // V2 controller has tokens but NOT this one: not buyable (ignores legacy) expect(result.current.isBuyable).toBe(false); }); }); + + describe('single token wrapper contract', () => { + it('returns the same result as batch buyability for one token', () => { + const token = getMockToken(); + setupV1Mocks(); + mockUseRampTokens.mockReturnValue({ + allTokens: [ + getMockRampToken({ + assetId: + 'eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f', + tokenSupported: true, + }), + ], + isLoading: false, + } as UseRampTokensResult); + + const { result } = renderHook(() => + tokenBuyabilityModule.useTokenBuyability(token), + ); + const { result: batchResult } = renderHook(() => + tokenBuyabilityModule.useTokensBuyability([token]), + ); + + const tokenKey = tokenBuyabilityModule.getTokenBuyabilityKey(token); + expect(result.current.isBuyable).toBe( + batchResult.current.buyabilityByTokenKey[tokenKey] ?? false, + ); + expect(result.current.isLoading).toBe(batchResult.current.isLoading); + }); + }); }); diff --git a/app/components/UI/Ramp/hooks/useTokenBuyability.ts b/app/components/UI/Ramp/hooks/useTokenBuyability.ts index c59630abdcb..aa9c48c3391 100644 --- a/app/components/UI/Ramp/hooks/useTokenBuyability.ts +++ b/app/components/UI/Ramp/hooks/useTokenBuyability.ts @@ -1,5 +1,10 @@ import { useMemo } from 'react'; -import { Hex, isCaipChainId, isCaipAssetType } from '@metamask/utils'; +import { + Hex, + isCaipChainId, + isCaipAssetType, + isHexString, +} from '@metamask/utils'; import { TokenI } from '../../Tokens/types'; import { useRampTokens } from './useRampTokens'; import { useRampsTokens } from './useRampsTokens'; @@ -13,6 +18,17 @@ export interface UseTokenBuyabilityResult { isLoading: boolean; } +interface UseTokensBuyabilityResult { + buyabilityByTokenKey: Record; + isLoading: boolean; +} + +interface BuyabilityTokenSource { + assetId?: string; + chainId?: string; + tokenSupported?: boolean; +} + /** * Builds the CAIP-19 assetId for a token, matching the format used by * useTokenActions.onBuy when navigating to the ramp flow. @@ -31,44 +47,105 @@ function buildRampAssetId(token: TokenI): string | undefined { } } +function getTokenCaipChainId(token: TokenI): string { + if (!token.chainId) { + return 'unknown-chain'; + } + + if (isCaipChainId(token.chainId)) { + return token.chainId; + } + + if (isHexString(token.chainId)) { + return toEvmCaipChainId(token.chainId as Hex); + } + + return token.chainId; +} + +export function getTokenBuyabilityKey(token: TokenI): string { + const caipChainId = getTokenCaipChainId(token); + return `${caipChainId}:${token.address.toLowerCase()}`; +} + +function getIsTokenBuyable( + token: TokenI, + rampsTokens: BuyabilityTokenSource[] | null, +): boolean { + if (!rampsTokens) { + return false; + } + + const chainId = getTokenCaipChainId(token); + const assetId = buildRampAssetId(token); + const isNative = token.isNative ?? false; + + const match = rampsTokens.find((rampToken) => { + if (!rampToken.assetId) { + return false; + } + + if (isNative) { + return ( + rampToken.chainId === chainId && rampToken.assetId.includes('/slip44:') + ); + } + + return assetId + ? rampToken.assetId.toLowerCase() === assetId.toLowerCase() + : false; + }); + + return match?.tokenSupported ?? false; +} + /** - * Hook that determines if a token can be bought via ramp services. + * Hook that determines if tokens can be bought via ramp services. * When unified V2 is enabled, checks against the RampsController's token list. * When V2 is disabled, uses the legacy token cache API. */ -export const useTokenBuyability = (token: TokenI): UseTokenBuyabilityResult => { +export const useTokensBuyability = ( + tokens: TokenI[], +): UseTokensBuyabilityResult => { const isV2Enabled = useRampsUnifiedV2Enabled(); const { allTokens: legacyAllTokens, isLoading: legacyLoading } = - useRampTokens(); + useRampTokens({ + fetchOnMount: !isV2Enabled, + }); const { tokens: controllerTokens, isLoading: controllerLoading } = useRampsTokens(); const isLoading = isV2Enabled ? controllerLoading : legacyLoading; - const isBuyable = useMemo(() => { - const tokens = isV2Enabled ? controllerTokens?.allTokens : legacyAllTokens; - if (!tokens) return false; - - const chainId = isCaipChainId(token.chainId) - ? token.chainId - : toEvmCaipChainId(token.chainId as Hex); - const assetId = buildRampAssetId(token); - const isNative = token.isNative ?? false; - - const match = tokens.find((tok) => { - if (!tok.assetId) return false; - if (isNative) { - return tok.chainId === chainId && tok.assetId.includes('/slip44:'); - } - return assetId - ? tok.assetId.toLowerCase() === assetId.toLowerCase() - : false; - }); + const rampsTokens = isV2Enabled + ? controllerTokens?.allTokens + : legacyAllTokens; + const buyabilityByTokenKey = useMemo( + () => + tokens.reduce>((accumulator, token) => { + const tokenKey = getTokenBuyabilityKey(token); + accumulator[tokenKey] = getIsTokenBuyable(token, rampsTokens ?? null); + return accumulator; + }, {}), + [tokens, rampsTokens], + ); + + return { buyabilityByTokenKey, isLoading }; +}; + +/** + * Hook that determines if a token can be bought via ramp services. + * Wrapper around useTokensBuyability for backwards compatibility. + */ +export const useTokenBuyability = (token: TokenI): UseTokenBuyabilityResult => { + const memoizedTokens = useMemo(() => [token], [token]); + + const { buyabilityByTokenKey, isLoading } = + useTokensBuyability(memoizedTokens); - return match?.tokenSupported ?? false; - }, [isV2Enabled, controllerTokens, legacyAllTokens, token]); + const tokenKey = getTokenBuyabilityKey(token); - return { isBuyable, isLoading }; + return { isBuyable: buyabilityByTokenKey[tokenKey] ?? false, isLoading }; }; export default useTokenBuyability; From 2ffae0cb5c7bf43af6476e4876c0f2e282d415c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Tue, 3 Mar 2026 14:38:53 -0700 Subject: [PATCH 4/6] feat(predict): track order type (FOK/FAK) in analytics (#26838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Adds `orderType` (FOK or FAK) to the Predict trade analytics events so we can track which order type is being used when users place orders. The order type is determined at preview time based on feature flags (`permit2Enabled`, `executors`, `fakOrdersEnabled`) and current Permit2 allowance state, then included on the `OrderPreview`. The controller reads `preview.orderType` and passes it to all three `trackPredictOrderEvent` calls (submitted, succeeded, failed). > **Note:** A temporary `hasPermit2Allowance()` check is included in `previewOrder()` as a workaround. This will be removed in a follow-up PR once `placeOrder()` guarantees the Permit2 allowance is set automatically before order submission. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-721 ## **Manual testing steps** ```gherkin Feature: Order type analytics tracking Scenario: user places a FOK order Given the predict feature is enabled with default flags (permit2 disabled) When user previews and places an order Then the analytics event includes order_type: "FOK" for submitted, succeeded, and failed statuses Scenario: user places a FAK order Given the predict feature is enabled with permit2Enabled, executors configured, and fakOrdersEnabled all true And the user has Permit2 allowance set When user previews and places an order Then the analytics event includes order_type: "FAK" for submitted, succeeded, and failed statuses ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes Polymarket `previewOrder`/`placeOrder` logic that determines `orderType` (FOK vs FAK), which can affect how trades are submitted/executed under Permit2/fee-collection conditions. While covered by new unit tests and largely additive for analytics, mistakes here could lead to incorrect order submission behavior or fee handling. > > **Overview** > Predict trade analytics now optionally include **`order_type` (FOK/FAK)** via a new `PredictEventProperties.ORDER_TYPE` key and a `PredictOrderType` type, with `PredictController.trackPredictOrderEvent` accepting/passing this value for submitted/succeeded/failed events. > > Polymarket order flow is updated to **compute `orderType` during `previewOrder()`** (default FOK; switch to FAK when Permit2 config + `fakOrdersEnabled`, and Permit2 allowance is ready only when fees require it) and to **use that computed order type in `placeOrder()`**; related Permit2 allowance checks are wrapped with logging/fallback, executor selection now uses `crypto.getRandomValues`, and tests are expanded to validate order-type behavior and analytics property inclusion. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4aa81a4cdf90b229dd576457601ed0bd06a7cbaf. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UI/Predict/constants/eventNames.ts | 1 + .../controllers/PredictController.test.ts | 30 ++++ .../Predict/controllers/PredictController.ts | 9 + .../polymarket/PolymarketProvider.test.ts | 160 ++++++++++++++++-- .../polymarket/PolymarketProvider.ts | 123 ++++++++++++-- app/components/UI/Predict/types/index.ts | 3 + 6 files changed, 299 insertions(+), 27 deletions(-) diff --git a/app/components/UI/Predict/constants/eventNames.ts b/app/components/UI/Predict/constants/eventNames.ts index de26b01602c..937ad44eea3 100644 --- a/app/components/UI/Predict/constants/eventNames.ts +++ b/app/components/UI/Predict/constants/eventNames.ts @@ -22,6 +22,7 @@ export const PredictEventProperties = { // Trade specific MARKET_TYPE: 'market_type', OUTCOME: 'outcome', + ORDER_TYPE: 'order_type', // Sensitive properties AMOUNT_USD: 'amount_usd', diff --git a/app/components/UI/Predict/controllers/PredictController.test.ts b/app/components/UI/Predict/controllers/PredictController.test.ts index 4996153bad3..630ce4b6ff7 100644 --- a/app/components/UI/Predict/controllers/PredictController.test.ts +++ b/app/components/UI/Predict/controllers/PredictController.test.ts @@ -5863,6 +5863,36 @@ describe('PredictController', () => { }); }); + it('includes orderType in analytics properties when provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + orderType: 'FAK', + }); + + expect(analytics.trackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + properties: expect.objectContaining({ + order_type: 'FAK', + }), + }), + ); + }); + }); + + it('omits orderType from analytics properties when not provided', async () => { + await withController(async ({ controller }) => { + await controller.trackPredictOrderEvent({ + status: 'submitted', + analyticsProperties: { marketId: 'test' }, + }); + + const eventArg = (analytics.trackEvent as jest.Mock).mock.calls[0][0]; + expect(eventArg.properties).not.toHaveProperty('order_type'); + }); + }); + it('calls analytics.trackEvent for trackMarketDetailsOpened', () => { withController(({ controller }) => { controller.trackMarketDetailsOpened({ diff --git a/app/components/UI/Predict/controllers/PredictController.ts b/app/components/UI/Predict/controllers/PredictController.ts index 58c75bef98f..e11c74d78e1 100644 --- a/app/components/UI/Predict/controllers/PredictController.ts +++ b/app/components/UI/Predict/controllers/PredictController.ts @@ -79,6 +79,7 @@ import { PredictClaim, PredictClaimStatus, PredictMarket, + PredictOrderType, PredictPosition, PredictPositionStatus, PredictPriceHistoryPoint, @@ -1046,6 +1047,7 @@ export class PredictController extends BaseController< failureReason, sharePrice, pnl, + orderType, }: { status: PredictTradeStatusValue; amountUsd?: number; @@ -1054,6 +1056,7 @@ export class PredictController extends BaseController< failureReason?: string; sharePrice?: number; pnl?: number; + orderType?: PredictOrderType; }): Promise { if (!analyticsProperties) { return; @@ -1107,6 +1110,9 @@ export class PredictController extends BaseController< ...(analyticsProperties.gameClock && { [PredictEventProperties.GAME_CLOCK]: analyticsProperties.gameClock, }), + ...(orderType && { + [PredictEventProperties.ORDER_TYPE]: orderType, + }), }; // Build sensitive properties @@ -1436,6 +1442,7 @@ export class PredictController extends BaseController< amountUsd, analyticsProperties, sharePrice, + orderType: preview.orderType, }); // Invalidate query cache (to avoid nonce issues) @@ -1496,6 +1503,7 @@ export class PredictController extends BaseController< analyticsProperties, completionDuration, sharePrice: realSharePrice, + orderType: preview.orderType, }); traceData = { success: true, side: preview.side }; @@ -1515,6 +1523,7 @@ export class PredictController extends BaseController< sharePrice, completionDuration, failureReason: errorMessage, + orderType: preview.orderType, }); // Update error state for Sentry integration diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index c7074280b61..9edb8fa2c44 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -1363,21 +1363,52 @@ describe('PolymarketProvider', () => { }); describe('previewOrder', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createPreviewSigner = () => ({ + address: '0x1234567890123456789012345678901234567890', + signTypedMessage: jest.fn(), + signPersonalMessage: jest.fn(), + }); + + const createPreviewOrderParams = () => ({ + marketId: 'market-123', + outcomeId: 'outcome-456', + outcomeTokenId: 'token-789', + side: Side.BUY, + size: 100, + signer: createPreviewSigner(), + }); + + const createPermit2PreviewProvider = (fakOrdersEnabled: boolean) => + createProvider({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled, + }); + + const mockPreviewOrderWithFees = () => { + mockPreviewOrder.mockResolvedValue({ + fees: { + totalFee: 1, + metamaskFee: 0.5, + providerFee: 0.5, + totalFeePercentage: 1, + collector: DEFAULT_FEE_COLLECTION_FLAG.collector, + }, + }); + }; + it('calls previewOrder utility function with correct parameters', async () => { const provider = createProvider(); - const mockSigner = { - address: '0x1234567890123456789012345678901234567890', - signTypedMessage: jest.fn(), - signPersonalMessage: jest.fn(), - }; const mockParams = { - marketId: 'market-123', - outcomeId: 'outcome-456', - outcomeTokenId: 'token-789', - side: Side.BUY, + ...createPreviewOrderParams(), amount: 100, - size: 100, - signer: mockSigner, }; await provider.previewOrder(mockParams); @@ -1387,6 +1418,62 @@ describe('PolymarketProvider', () => { feeCollection: DEFAULT_FEE_COLLECTION_FLAG, }); }); + it('returns FOK orderType by default', async () => { + const provider = createProvider(); + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FOK'); + }); + + it.each([ + { + permit2Ready: true, + fakOrdersEnabled: true, + expectedOrderType: 'FAK', + }, + { + permit2Ready: false, + fakOrdersEnabled: true, + expectedOrderType: 'FOK', + }, + { + permit2Ready: true, + fakOrdersEnabled: false, + expectedOrderType: 'FOK', + }, + ] as const)( + 'returns $expectedOrderType orderType when permit2Ready=$permit2Ready and fakOrdersEnabled=$fakOrdersEnabled', + async ({ permit2Ready, fakOrdersEnabled, expectedOrderType }) => { + mockPreviewOrderWithFees(); + mockHasPermit2Allowance.mockResolvedValue(permit2Ready); + const provider = createPermit2PreviewProvider(fakOrdersEnabled); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe(expectedOrderType); + }, + ); + + it('returns FOK orderType when permit2 allowance check throws', async () => { + mockPreviewOrderWithFees(); + mockHasPermit2Allowance.mockRejectedValue(new Error('RPC timeout')); + const provider = createPermit2PreviewProvider(true); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FOK'); + }); + + it('returns FAK orderType when fees are absent and FAK flags are enabled', async () => { + mockPreviewOrder.mockResolvedValue({}); + mockHasPermit2Allowance.mockResolvedValue(true); + const provider = createPermit2PreviewProvider(true); + + const result = await provider.previewOrder(createPreviewOrderParams()); + + expect(result.orderType).toBe('FAK'); + expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); + }); }); describe('API key caching', () => { @@ -1867,6 +1954,57 @@ describe('PolymarketProvider', () => { ); }); }); + describe('placeOrder FAK order type for sell orders', () => { + it('submits FAK order type for sell order without fees when FAK is enabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: true, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FAK' }), + }), + ); + expect(mockHasPermit2Allowance).not.toHaveBeenCalled(); + }); + + it('submits FOK order type for sell order without fees when FAK is disabled', async () => { + jest.clearAllMocks(); + const { provider, mockSigner } = setupPlaceOrderTest({ + feeCollection: { + ...DEFAULT_FEE_COLLECTION_FLAG, + permit2Enabled: true, + executors: ['0xexecutor1'], + }, + fakOrdersEnabled: false, + }); + const preview = createMockOrderPreview({ + side: Side.SELL, + fees: undefined, + }); + + await provider.placeOrder({ preview, signer: mockSigner }); + + expect(mockSubmitClobOrder).toHaveBeenCalledWith( + expect.objectContaining({ + clobOrder: expect.objectContaining({ orderType: 'FOK' }), + }), + ); + }); + }); + describe('placeOrder edge cases', () => { it('places order without fee authorization when totalFee is zero', async () => { // Clear mock to ensure clean state for this test diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 3cfb5b8a38d..3a88fbb1a8f 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -187,6 +187,48 @@ export class PolymarketProvider implements PredictProvider { }; } + #hasPermit2Config(params: { + permit2Enabled?: boolean; + executors?: string[]; + }): boolean { + return ( + params.permit2Enabled === true && + Array.isArray(params.executors) && + params.executors.length > 0 + ); + } + + #shouldUseFakOrderType({ + permit2Enabled, + executors, + fakOrdersEnabled, + }: { + permit2Enabled?: boolean; + executors?: string[]; + fakOrdersEnabled: boolean; + }): boolean { + return ( + this.#hasPermit2Config({ permit2Enabled, executors }) && + fakOrdersEnabled === true + ); + } + + async #isPermit2AllowanceReady(ownerAddress: string): Promise { + const safeAddress = + this.#accountStateByAddress.get(ownerAddress)?.address ?? + computeProxyAddress(ownerAddress); + + try { + return await hasPermit2Allowance({ address: safeAddress }); + } catch (error) { + DevLogger.log('PolymarketProvider: Permit2 allowance check failed', { + error, + ownerAddress, + }); + return false; + } + } + public async getMarketDetails({ marketId, }: { @@ -977,19 +1019,49 @@ export class PolymarketProvider implements PredictProvider { signer: Signer; }, ): Promise { - const { feeCollection } = this.#getFeatureFlags(); + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); const basePreview = await previewOrder({ ...params, feeCollection }); + // Determine intended order type from feature flags. + // FAK is used when Permit2 config is active and FAK orders are enabled. + // The Permit2 allowance check is only needed when fees must be collected. + let orderType = OrderType.FOK; + + const couldUseFak = this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }); + + if (couldUseFak) { + const hasFees = + basePreview.fees !== undefined && basePreview.fees.totalFee > 0; + if (hasFees) { + // TODO: remove this once placeOrder guarantees Permit2 allowance + // is set automatically before order submission. + const permit2Ready = await this.#isPermit2AllowanceReady( + params.signer.address, + ); + if (permit2Ready) { + orderType = OrderType.FAK; + } + } else { + // No fees to collect via Permit2 — FAK can be used directly. + orderType = OrderType.FAK; + } + } + if (params.signer) { if (this.isRateLimited(params.signer.address)) { return { ...basePreview, + orderType, rateLimited: true, }; } } - return basePreview; + return { ...basePreview, orderType }; } public async placeOrder( @@ -1145,18 +1217,19 @@ export class PolymarketProvider implements PredictProvider { // Determine fees, permit2, and order type BEFORE building clobOrder // so the HMAC signature covers the correct orderType. - const { fakOrdersEnabled } = this.#getFeatureFlags(); - const shouldUsePermit2 = - fees?.permit2Enabled === true && - Array.isArray(fees.executors) && - fees.executors.length > 0; + const { feeCollection, fakOrdersEnabled } = this.#getFeatureFlags(); + const shouldUsePermit2 = this.#hasPermit2Config({ + permit2Enabled: fees?.permit2Enabled, + executors: fees?.executors, + }); let feeAuthorization: | SafeFeeAuthorization | Permit2FeeAuthorization | undefined; let executor: string | undefined; - let shouldUseFakOrderType = false; + let orderType: OrderType = OrderType.FOK; + let permit2FeeReady = false; if (fees !== undefined && fees.totalFee > 0) { const safeAddress = computeProxyAddress(signer.address); @@ -1164,21 +1237,23 @@ export class PolymarketProvider implements PredictProvider { parseUnits(fees.totalFee.toString(), 6).toString(), ); - if (shouldUsePermit2 && fees.executors) { - const permit2Ready = await hasPermit2Allowance({ - address: safeAddress, - }); + if (shouldUsePermit2) { + const executors = fees.executors ?? []; + const permit2Ready = await this.#isPermit2AllowanceReady( + signer.address, + ); if (permit2Ready) { - executor = - fees.executors[Math.floor(Math.random() * fees.executors.length)]; + permit2FeeReady = true; + const randomIndex = new Uint32Array(1); + global.crypto.getRandomValues(randomIndex); + executor = executors[randomIndex[0] % executors.length]; feeAuthorization = await createPermit2FeeAuthorization({ safeAddress, signer, amount: feeAmountInUsdc, spender: executor, }); - shouldUseFakOrderType = fakOrdersEnabled === true; } else { feeAuthorization = await createSafeFeeAuthorization({ safeAddress, @@ -1197,10 +1272,26 @@ export class PolymarketProvider implements PredictProvider { } } + // Determine order type independently of fee authorization. + // FAK depends on feature flags only; the Permit2 allowance check + // is only needed when fees must be collected via Permit2. + if ( + this.#shouldUseFakOrderType({ + permit2Enabled: feeCollection.permit2Enabled, + executors: feeCollection.executors, + fakOrdersEnabled, + }) + ) { + const hasFees = fees !== undefined && fees.totalFee > 0; + if (!hasFees || permit2FeeReady) { + orderType = OrderType.FAK; + } + } + const clobOrder = { order: { ...signedOrder, side, salt: parseInt(signedOrder.salt) }, owner: signerApiKey.apiKey, - orderType: shouldUseFakOrderType ? OrderType.FAK : OrderType.FOK, + orderType, }; const body = JSON.stringify(clobOrder); diff --git a/app/components/UI/Predict/types/index.ts b/app/components/UI/Predict/types/index.ts index 672c524a22d..51464d2ee18 100644 --- a/app/components/UI/Predict/types/index.ts +++ b/app/components/UI/Predict/types/index.ts @@ -7,6 +7,8 @@ export enum Side { SELL = 'SELL', } +export type PredictOrderType = 'FOK' | 'FAK'; + export enum PredictPriceHistoryInterval { ONE_HOUR = '1h', SIX_HOUR = '6h', @@ -470,6 +472,7 @@ export interface OrderPreview { // For sell orders, we can store the position ID // so we can perform optimistic updates positionId?: string; + orderType?: PredictOrderType; } export type OrderResult = Result<{ From 7f0ffc3763be0d510fb79e077c83913ec4735a98 Mon Sep 17 00:00:00 2001 From: Curtis David Date: Tue, 3 Mar 2026 17:55:23 -0500 Subject: [PATCH 5/6] test: setup full ramps e2e flow using ramps provider (#26951) ## **Description** The purpose of this PR is to add E2E smoke test coverage for the Unified Buy V2 flow, validating both routing paths a user can take when purchasing crypto through the ramps experience. - Two smoke test scenarios: new user (no prior completed orders) routes through the native Transak deposit flow (KYC/OTP + bank transfer); returning user (prior non-Transak aggregator order) routes through the aggregator WebView buy flow - New page objects: KYCScreen, OrderDetailsView, ActivitiesView, ToastModal, extended BuildQuoteView - Full Transak native flow mocking: auth/login, auth/verify, quotes, user details, KYC requirements, user limits, create order - Ramps mock infrastructure refactor: introduced setupDepositOnRampMocks and setupBuyOnRampMocks as single-purpose helpers; - setupRegionAwareOnRampMocks retained as the base for build-quote-only tests; default providerType corrected from native to aggregator - TestID additions: crypto/fiat amount testIDs moved to DisplayOrderListItem (the actually-rendered component), testIDs added to EnterEmail, OtpCode, VerifyIdentity, and ActivitiesView; removed dead OrdersListTestIds aggregate export ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Mostly E2E test, selector, and mock refactors, but it substantially changes the on-ramp mocking setup and fixtures used across ramps smoke tests, which could cause test flakiness or broken flows if assumptions drift. > > **Overview** > Adds end-to-end smoke coverage for Unified Buy V2 across both routing paths: **new users** go through the native Transak KYC/OTP deposit flow, while **returning users** go through the aggregator (widget/WebView) buy flow, including verification of the resulting order in the Activity transfers list and basic analytics assertions. > > Refactors ramps API mocking to provide dedicated helpers (`setupDepositOnRampMocks`, `setupBuyOnRampMocks`) and expands mocked endpoints for the native Transak flow (auth, quotes, user/KYC/limits, order creation, translation, and stateful order polling), while splitting buy vs deposit order-status mocking. > > Improves Detox automation by adding/centralizing `testID` selectors for ramps screens (`EnterEmail`, `OtpCode`, `VerifyIdentity`, `OrderDetails`) and for `OrdersList` rows/amounts, plus new page objects (`KYCScreen`, `OrderDetailsView`) and enhanced page object actions (e.g., wait-for-enabled Continue). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0b768fea6143189ca0743193de74ca97a1008e01. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/OrdersList/OrdersList.testIds.ts | 14 + .../Views/OrdersList/OrdersList.tsx | 43 ++- .../__snapshots__/OrdersList.test.tsx.snap | 66 ++++ .../Views/NativeFlow/EnterEmail.testIds.ts | 4 + .../UI/Ramp/Views/NativeFlow/EnterEmail.tsx | 3 + .../Ramp/Views/NativeFlow/OtpCode.testIds.ts | 6 + .../UI/Ramp/Views/NativeFlow/OtpCode.tsx | 9 +- .../NativeFlow/VerifyIdentity.testIds.ts | 5 + .../Ramp/Views/NativeFlow/VerifyIdentity.tsx | 6 +- .../__snapshots__/EnterEmail.test.tsx.snap | 2 + .../__snapshots__/OtpCode.test.tsx.snap | 1 + .../VerifyIdentity.test.tsx.snap | 1 + .../Ramp/Views/OrderDetails/OrderContent.tsx | 3 + .../OrderDetails/OrderDetails.testIds.ts | 6 + .../Ramp/Views/OrderDetails/OrderDetails.tsx | 3 +- .../__snapshots__/OrderContent.test.tsx.snap | 2 + .../ActivityView/ActivitiesView.testIds.ts | 1 + .../ramps/onramp-persona-data.ts | 14 + .../mock-responses/ramps/ramps-mocks.ts | 328 ++++++++++++++---- .../ramps-buy-order-checkout-response.ts | 16 + .../ramps-buy-order-status-response.ts | 45 +++ .../ramps-deposit-order-status-response.ts | 42 +++ .../responses/ramps-providers-response.ts | 7 +- .../ramps/responses/ramps-quotes-response.ts | 17 +- .../ramps/transak/transak-auth-response.ts | 24 ++ .../ramps/transak/transak-order-response.ts | 11 + .../transak-payments-override-response.ts | 42 +++ .../ramps/transak/transak-quotes-response.ts | 23 ++ .../transak-ramps-translate-response.ts | 12 + .../ramps/transak/transak-user-response.ts | 41 +++ tests/framework/fixtures/FixtureBuilder.ts | 3 + tests/page-objects/Ramps/BuildQuoteView.ts | 11 + tests/page-objects/Ramps/KYCScreen.ts | 60 ++++ tests/page-objects/Ramps/OrderDetailsView.ts | 37 ++ .../Transactions/ActivitiesView.ts | 45 +++ tests/smoke/ramps/onramp-unified-buy.spec.ts | 181 ++++++++-- 36 files changed, 1023 insertions(+), 111 deletions(-) create mode 100644 app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/OtpCode.testIds.ts create mode 100644 app/components/UI/Ramp/Views/NativeFlow/VerifyIdentity.testIds.ts create mode 100644 app/components/UI/Ramp/Views/OrderDetails/OrderDetails.testIds.ts create mode 100644 tests/api-mocking/mock-responses/ramps/onramp-persona-data.ts create mode 100644 tests/api-mocking/mock-responses/ramps/responses/ramps-buy-order-checkout-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/responses/ramps-buy-order-status-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/responses/ramps-deposit-order-status-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-auth-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-order-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-payments-override-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-quotes-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-ramps-translate-response.ts create mode 100644 tests/api-mocking/mock-responses/ramps/transak/transak-user-response.ts create mode 100644 tests/page-objects/Ramps/KYCScreen.ts create mode 100644 tests/page-objects/Ramps/OrderDetailsView.ts diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts new file mode 100644 index 00000000000..86b14cac06e --- /dev/null +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.testIds.ts @@ -0,0 +1,14 @@ +export type RampsOrderTypeSlug = 'buy' | 'sell' | 'deposit'; + +export const getOrderRowTestId = (type: RampsOrderTypeSlug, index: number) => + `orders-list-row-${type}-${index}`; + +export const getOrderRowCryptoAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-crypto-amount-${type}-${index}`; + +export const getOrderRowFiatAmountTestId = ( + type: RampsOrderTypeSlug, + index: number, +) => `orders-list-fiat-amount-${type}-${index}`; diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx index a4034f8d2a3..69d549095ba 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/OrdersList.tsx @@ -41,6 +41,12 @@ import ListItemColumn, { WidthType, } from '../../../../../../component-library/components/List/ListItemColumn'; import ListItemColumnEnd from '../../components/ListItemColumnEnd'; +import { + getOrderRowTestId, + getOrderRowCryptoAmountTestId, + getOrderRowFiatAmountTestId, + type RampsOrderTypeSlug, +} from './OrdersList.testIds'; type filterType = 'ALL' | 'PURCHASE' | 'SELL'; @@ -92,12 +98,25 @@ function getStatusColorAndText( return [statusColor, statusText]; } -function DisplayOrderListItem({ item }: { item: DisplayOrder }) { +function getOrderTypeSlug(orderType: string): RampsOrderTypeSlug { + if (orderType === 'DEPOSIT') return 'deposit'; + if (orderType === 'SELL') return 'sell'; + return 'buy'; +} + +function DisplayOrderListItem({ + item, + index, +}: { + item: DisplayOrder; + index: number; +}) { const isBuy = item.orderType === 'BUY' || item.orderType === 'DEPOSIT'; const [statusColor, statusText] = getStatusColorAndText( item.status, item.orderType, ); + const typeSlug = getOrderTypeSlug(item.orderType); const title = item.providerName ? `${item.providerName}: ${strings( @@ -128,10 +147,17 @@ function DisplayOrderListItem({ item }: { item: DisplayOrder }) { - + {item.cryptoAmount} {item.cryptoCurrencySymbol} - + {item.fiatAmount == null ? '...' : addCurrencySymbol( @@ -256,8 +282,15 @@ function OrdersList() { ], ); - const renderItem = ({ item }: { item: DisplayOrder }) => ( + const renderItem = ({ + item, + index, + }: { + item: DisplayOrder; + index: number; + }) => ( - + ); diff --git a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap index cb8638a72fc..5091ad05fb1 100644 --- a/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap +++ b/app/components/UI/Ramp/Aggregator/Views/OrdersList/__snapshots__/OrdersList.test.tsx.snap @@ -476,6 +476,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -599,6 +601,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -623,6 +626,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-2" underlayColor="#f3f3f4" > 0.01231324 @@ -746,6 +751,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-2" > $34.23 @@ -770,6 +776,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.5 @@ -893,6 +901,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $1000 @@ -917,6 +926,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-4" underlayColor="#f3f3f4" > 100 @@ -1040,6 +1051,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-4" > $100 @@ -1064,6 +1076,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 20 @@ -1187,6 +1201,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $20 @@ -1211,6 +1226,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-6" underlayColor="#f3f3f4" > 0 @@ -1334,6 +1351,7 @@ exports[`OrdersList renders buy only correctly when pressing buy filter 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-6" > ... @@ -1836,6 +1854,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -1959,6 +1979,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -1983,6 +2004,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-2" underlayColor="#f3f3f4" > 0.01231324 @@ -2106,6 +2129,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -2130,6 +2154,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.01231324 @@ -2253,6 +2279,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -2277,6 +2304,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-4" underlayColor="#f3f3f4" > 0.5 @@ -2400,6 +2429,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > $1000 @@ -2424,6 +2454,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 100 @@ -2547,6 +2579,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $100 @@ -2571,6 +2604,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-6" underlayColor="#f3f3f4" > 20 @@ -2694,6 +2729,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-6" > $20 @@ -2718,6 +2754,7 @@ exports[`OrdersList renders correctly 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-7" underlayColor="#f3f3f4" > 0 @@ -2841,6 +2879,7 @@ exports[`OrdersList renders correctly 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-7" > ... @@ -4117,6 +4156,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-1" underlayColor="#f3f3f4" > 0.01231324 @@ -4240,6 +4281,7 @@ exports[`OrdersList renders sell only correctly when pressing sell filter 1`] = "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -4658,6 +4700,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-1" underlayColor="#f3f3f4" > 0.01231324 @@ -4781,6 +4825,7 @@ exports[`OrdersList resets filter to all after other filter was set 1`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-1" > $34.23 @@ -5283,6 +5328,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-1" underlayColor="#f3f3f4" > 0.01231324 @@ -5406,6 +5453,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-1" > $34.23 @@ -5430,6 +5478,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-sell-2" underlayColor="#f3f3f4" > 0.01231324 @@ -5553,6 +5603,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-sell-2" > $34.23 @@ -5577,6 +5628,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-3" underlayColor="#f3f3f4" > 0.01231324 @@ -5700,6 +5753,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-3" > $34.23 @@ -5724,6 +5778,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-4" underlayColor="#f3f3f4" > 0.5 @@ -5847,6 +5903,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-4" > $1000 @@ -5871,6 +5928,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-5" underlayColor="#f3f3f4" > 100 @@ -5994,6 +6053,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-5" > $100 @@ -6018,6 +6078,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-deposit-6" underlayColor="#f3f3f4" > 20 @@ -6141,6 +6203,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-deposit-6" > $20 @@ -6165,6 +6228,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "borderColor": "#b4b4b566", } } + testID="orders-list-row-buy-7" underlayColor="#f3f3f4" > 0 @@ -6288,6 +6353,7 @@ exports[`OrdersList resets filter to all after other filter was set 2`] = ` "lineHeight": 22, } } + testID="orders-list-fiat-amount-buy-7" > ... diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts new file mode 100644 index 00000000000..3be1da5e554 --- /dev/null +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.testIds.ts @@ -0,0 +1,4 @@ +export const EnterEmailSelectorsIDs = { + EMAIL_INPUT: 'ramps-enter-email-input', + SEND_EMAIL_BUTTON: 'ramps-enter-email-send-button', +}; diff --git a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx index da96ff54c4a..50fe451ed5f 100644 --- a/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx +++ b/app/components/UI/Ramp/Views/NativeFlow/EnterEmail.tsx @@ -29,6 +29,7 @@ import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; import { MetaMetricsEvents } from '../../../../../core/Analytics'; import { useTransakController } from '../../hooks/useTransakController'; import { parseUserFacingError } from '../../utils/parseUserFacingError'; +import { EnterEmailSelectorsIDs } from './EnterEmail.testIds'; export interface V2EnterEmailParams { amount?: string; @@ -149,6 +150,7 @@ const V2EnterEmail = () => { {