From f295f73dcc3d3bfb2909431d5021d2ee83c1632a Mon Sep 17 00:00:00 2001 From: sleepytanya <104780023+sleepytanya@users.noreply.github.com> Date: Wed, 4 Mar 2026 03:50:33 -0500 Subject: [PATCH 01/10] test: update "Ethereum Provider Snap Tests" smoke tests to be BIP-44 compatible (#26915) ## **Description** Remove BIP-44 feature flag mock from Ethereum Provider Snap tests so they run with production settings. https://github.com/MetaMask/metamask-mobile/issues/24151 https://consensyssoftware.atlassian.net/browse/MMQA-1511 ## **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] > **Low Risk** > Low risk: only adjusts smoke-test feature flag mocking, with no production code changes; main impact is potential test behavior drift if the test implicitly relied on the disabled flag. > > **Overview** > Updates the Ethereum Provider Snap smoke test to run with more production-like remote feature flag settings by **removing the explicit override that disabled** `remoteFeatureMultichainAccountsAccountDetailsV2`. > > The test now mocks only `confirmationFeatureFlags` (plus genesis block stubs) when calling `setupRemoteFeatureFlagsMock`, and drops the unused feature-flag import. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 157043eff1098e90430f916802c5f6b4e8577a4c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../snaps/test-snap-ethereum-provider.spec.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts b/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts index 97d18f50a4a..48484acfb1e 100644 --- a/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts +++ b/tests/smoke/snaps/test-snap-ethereum-provider.spec.ts @@ -9,10 +9,7 @@ import ConnectBottomSheet from '../../page-objects/Browser/ConnectBottomSheet'; import RequestTypes from '../../page-objects/Browser/Confirmations/RequestTypes'; import { Mockttp } from 'mockttp'; import { setupRemoteFeatureFlagsMock } from '../../api-mocking/helpers/remoteFeatureFlagsHelper'; -import { - confirmationFeatureFlags, - remoteFeatureMultichainAccountsAccountDetailsV2, -} from '../../api-mocking/mock-responses/feature-flags-mocks'; +import { confirmationFeatureFlags } from '../../api-mocking/mock-responses/feature-flags-mocks'; import { mockGenesisBlocks } from './mocks'; jest.setTimeout(150_000); @@ -25,11 +22,10 @@ describe(FlaskBuildTests('Ethereum Provider Snap Tests'), () => { restartDevice: true, skipReactNativeReload: true, testSpecificMock: async (mockServer: Mockttp) => { - await setupRemoteFeatureFlagsMock(mockServer, { - ...Object.assign({}, ...confirmationFeatureFlags), - ...remoteFeatureMultichainAccountsAccountDetailsV2(false), - }); - + await setupRemoteFeatureFlagsMock( + mockServer, + Object.assign({}, ...confirmationFeatureFlags), + ); await mockGenesisBlocks(mockServer); }, }, From b2d383c6afe469d1694f31f82e8d2f7b074c0029 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Wed, 4 Mar 2026 09:50:52 +0100 Subject: [PATCH 02/10] test(snaps): Add name-lookup E2E (#26765) ## **Description** Port extension name-lookup test to mobile for increased coverage. The PR adjusts a few shared utilities to make the tests more stable and enable the flow tested in this test. ## **Changelog** CHANGELOG entry: null ## **Related issues** Closes: https://github.com/MetaMask/snaps/issues/3572 --- > [!NOTE] > **Low Risk** > Low risk: changes are limited to Detox E2E test code and test selectors, with a small tap-stability tweak that could affect test flakiness but not app behavior. > > **Overview** > Adds a new smoke E2E test (`test-snap-name-lookup.spec.ts`) that installs the **Name Lookup** test snap and verifies a domain recipient resolves to the expected address during the send/confirmation flow. > > Extends test infrastructure to support the scenario by adding the `connectNameLookupButton` selector, adding a `tapAdvancedDetails()` helper on `TransactionConfirmView`, and making `RedesignedSendView.pressReviewButton()` use `checkStability` when tapping. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit d3d21a06fd542541db9af1a01b71fd10d44ddadd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- tests/page-objects/Send/RedesignedSendView.ts | 1 + .../Send/TransactionConfirmView.ts | 6 ++ .../selectors/Browser/TestSnaps.selectors.ts | 1 + .../smoke/snaps/test-snap-name-lookup.spec.ts | 77 +++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 tests/smoke/snaps/test-snap-name-lookup.spec.ts diff --git a/tests/page-objects/Send/RedesignedSendView.ts b/tests/page-objects/Send/RedesignedSendView.ts index 36dcb74e268..567a0daec68 100644 --- a/tests/page-objects/Send/RedesignedSendView.ts +++ b/tests/page-objects/Send/RedesignedSendView.ts @@ -127,6 +127,7 @@ class SendView { await Utilities.waitForElementToBeEnabled(this.reviewButton); await Gestures.waitAndTap(this.reviewButton, { elemDescription: 'Review button', + checkStability: true, }); } diff --git a/tests/page-objects/Send/TransactionConfirmView.ts b/tests/page-objects/Send/TransactionConfirmView.ts index ab17d40a402..9fb8b53aa45 100644 --- a/tests/page-objects/Send/TransactionConfirmView.ts +++ b/tests/page-objects/Send/TransactionConfirmView.ts @@ -164,6 +164,12 @@ class TransactionConfirmationView { elemDescription: 'Gas Fee Token Pill in Confirmation View', }); } + + async tapAdvancedDetails(): Promise { + await Gestures.waitAndTap(RowComponents.AdvancedDetails, { + elemDescription: 'Advanced details in Confirmation View', + }); + } } export default new TransactionConfirmationView(); diff --git a/tests/selectors/Browser/TestSnaps.selectors.ts b/tests/selectors/Browser/TestSnaps.selectors.ts index c73980ba39e..ab786a90d3c 100644 --- a/tests/selectors/Browser/TestSnaps.selectors.ts +++ b/tests/selectors/Browser/TestSnaps.selectors.ts @@ -21,6 +21,7 @@ export const TestSnapViewSelectorWebIDS = { connectInteractiveButton: 'connectinteractive-ui', connectManageStateButton: 'connectmanage-state', connectMultichainProviderButton: 'connectmultichain-provider', + connectNameLookupButton: 'connectname-lookup', connectNetworkAccessButton: 'connectnetwork-access', connectEthereumProviderButton: 'connectethereum-provider', connectStateButton: 'connectstate', diff --git a/tests/smoke/snaps/test-snap-name-lookup.spec.ts b/tests/smoke/snaps/test-snap-name-lookup.spec.ts new file mode 100644 index 00000000000..e2d2a36307d --- /dev/null +++ b/tests/smoke/snaps/test-snap-name-lookup.spec.ts @@ -0,0 +1,77 @@ +import { FlaskBuildTests } from '../../tags'; +import { loginToApp } from '../../flows/wallet.flow'; +import { navigateToBrowserView } from '../../flows/browser.flow'; +import FixtureBuilder from '../../framework/fixtures/FixtureBuilder'; +import { withFixtures } from '../../framework/fixtures/FixtureHelper'; +import TestSnaps from '../../page-objects/Browser/TestSnaps'; +import TabBarComponent from '../../page-objects/wallet/TabBarComponent'; +import WalletView from '../../page-objects/wallet/WalletView'; +import RedesignedSendView from '../../page-objects/Send/RedesignedSendView'; +import { Assertions, Gestures, LocalNode, Matchers } from '../../framework'; +import BrowserView from '../../page-objects/Browser/BrowserView'; +import { AnvilPort } from '../../framework/fixtures/FixtureUtils'; +import { AnvilManager } from '../../seeder/anvil-manager'; +import TransactionConfirmView from '../../page-objects/Send/TransactionConfirmView'; +import TokenOverview from '../../page-objects/wallet/TokenOverview'; + +jest.setTimeout(150_000); + +const TOKEN = 'Ethereum'; + +describe(FlaskBuildTests('Name Lookup Snap Tests'), () => { + it('displays the resolved recipient address in the send flow', async () => { + await withFixtures( + { + fixture: ({ localNodes }: { localNodes?: LocalNode[] }) => { + const node = localNodes?.[0] as unknown as AnvilManager; + + return new FixtureBuilder() + .withNetworkController({ + providerConfig: { + chainId: '0x1', + rpcUrl: `http://localhost:${node.getPort() ?? AnvilPort()}`, + type: 'custom', + nickname: 'Local RPC', + ticker: 'ETH', + }, + }) + .build(); + }, + restartDevice: true, + skipReactNativeReload: true, + }, + async () => { + await loginToApp(); + await navigateToBrowserView(); + await TestSnaps.navigateToTestSnap(); + + await TestSnaps.installSnap('connectNameLookupButton'); + + await BrowserView.tapCloseBrowserButton(); + await TabBarComponent.tapHome(); + await device.disableSynchronization(); + await WalletView.waitForTokenToBeReady(TOKEN); + await WalletView.tapOnToken(TOKEN); + await TokenOverview.tapSendButton(); + + const domain = 'metamask.domain'; + await RedesignedSendView.enterZeroAmount(); + await RedesignedSendView.pressContinueButton(); + await RedesignedSendView.inputRecipientAddress(domain); + await RedesignedSendView.pressReviewButton(); + await TransactionConfirmView.tapAdvancedDetails(); + + await Gestures.waitAndTap( + Matchers.getElementByText( + domain, + device.getPlatform() === 'ios' ? 1 : 0, + ), + ); + + await Assertions.expectTextDisplayed( + '0xc0ffee254729296a45a3885639ac7e10f9d54979', + ); + }, + ); + }); +}); From 422b4b20b56903f204b43a62350d7876054312e6 Mon Sep 17 00:00:00 2001 From: sophieqgu <37032128+sophieqgu@users.noreply.github.com> Date: Wed, 4 Mar 2026 04:07:50 -0500 Subject: [PATCH 03/10] chore: handle rewards 403 auth with retry (#26834) ## **Description** https://consensyssoftware.atlassian.net/browse/RWDS-1055 Handle 403 auth error with retry first before showing error banner. Two major changes are made: 1. Data Service: Centralized 403 detection in `makeRequest` and throws AuthorizationFailedError for controller 2. Controller: `withAuthRetry` Wrapper to performSilentAuth before throwing error, to suppress error banner during re-auth ## **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** - [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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Touches rewards authentication/session-token handling and retries multiple API-backed controller methods, which can impact user state and caching if the retry/reauth flow misbehaves. Changes are scoped to rewards and covered by updated/new tests, reducing rollout risk. > > **Overview** > Adds centralized 403 handling for authenticated Rewards API calls: `RewardsDataService.makeRequest` now throws `AuthorizationFailedError` when a subscription-scoped request returns 403 (and removes prior message-based detection in `getSeasonStatus`). > > Updates `RewardsController` to wrap several subscription-authenticated operations (`getSeasonStatus`, points events, referral details, boosts, unlocked rewards, snapshots, claim/apply/opt-out/bonus-code validation) in a new `#withAuthRetry` helper that triggers a silent re-auth (`#performReauthForSubscription`) and retries the original call once. Concurrent 403s for the same subscription are coalesced via a per-subscription promise map, and failed reauth now invalidates subscription caches/state before rethrowing. > > Tests are adjusted to match the new error messages/logging and add coverage for 403 detection in the data service plus reauth+retry behavior (including active boosts) and non-403 no-retry behavior. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7f2b4b261685a5d31b4f2b7eaa68da4c2a6b7f40. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../RewardsController.test.ts | 216 +++++++--- .../rewards-controller/RewardsController.ts | 375 ++++++++++-------- .../services/rewards-data-service.test.ts | 139 ++++++- .../services/rewards-data-service.ts | 12 +- 4 files changed, 477 insertions(+), 265 deletions(-) diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts index 698fd02a73e..078318e5edb 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.test.ts @@ -2459,11 +2459,6 @@ describe('RewardsController', () => { await expect(controller.getPointsEvents(mockRequest)).rejects.toThrow( 'API error', ); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get points events:', - 'API error', - ); }); describe('balance updated event emission', () => { @@ -4997,9 +4992,10 @@ describe('RewardsController', () => { it('reauthenticates and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id', @@ -5127,17 +5123,14 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('handles 403 error, reauth, and re-throw error if reauth failed', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockReauthError = new Error('Reauth failed'); const mockAccount = { @@ -5198,12 +5191,10 @@ describe('RewardsController', () => { state.pointsEvents = {}; }); - // Mock the messenger calls - let getSeasonStatusCallCount = 0; + // Mock the messenger calls — login rejects to simulate reauth failure localMockMessenger.call.mockImplementation( async (method, ..._args): Promise => { if (method === 'RewardsDataService:getSeasonStatus') { - getSeasonStatusCallCount++; return Promise.reject(mock403Error); } if (method === 'AccountsController:getSelectedMultichainAccount') { @@ -5219,22 +5210,14 @@ describe('RewardsController', () => { }, ); - const invalidateSubscriptionCacheSpy = jest.spyOn( - testableController, - 'invalidateSubscriptionCache' as any, - ); - const invalidateSubscriptionAndAccountsSpy = jest.spyOn( - testableController, - 'invalidateSubscriptionAndAccounts' as any, - ); - - // Act & Assert + // Act & Assert — reauth login fails (silently absorbed by performSilentAuth), + // performReauthForSubscription throws, caches are invalidated, and the + // reauth error propagates (retry is never reached) await expect( testableController.getSeasonStatus(mockSubscriptionId, mockSeasonId), - ).rejects.toThrow(mock403Error); + ).rejects.toThrow(`Reauth failed for subscription ${mockSubscriptionId}`); - // Verify reauth was attempted and status was fetched again - expect(getSeasonStatusCallCount).toBe(2); + // Verify reauth was attempted expect(localMockMessenger.call).toHaveBeenCalledWith( 'AccountsController:getSelectedMultichainAccount', ); @@ -5245,25 +5228,16 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to reauth with a valid account after 403 error', - mock403Error.message, - ); - expect(invalidateSubscriptionCacheSpy).toHaveBeenCalledWith( - mockSubscriptionId, - ); - expect(invalidateSubscriptionAndAccountsSpy).toHaveBeenCalledWith( - mockSubscriptionId, + 'RewardsController: Attempting reauth with active account after 403', ); }); it('reauthenticates with active account and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id', @@ -5403,18 +5377,16 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('reauthenticates with non-active account and retries after 403 error', async () => { // Arrange + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); jest.spyOn(Date, 'now').mockImplementation(() => 1000000); const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const mockAccount = { id: 'test-account-id-2', @@ -5562,17 +5534,14 @@ describe('RewardsController', () => { }), ); expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Successfully fetched season status after reauth', + 'RewardsController: Attempting reauth with active account after 403', ); }); it('throws authorization error when account for subscription not found after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const testSubscriptionId = 'test-sub-id'; @@ -5664,7 +5633,7 @@ describe('RewardsController', () => { it('throws authorization error when converted account not found after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const testSubscriptionId = 'test-sub-id'; const mockAccount = { @@ -5772,7 +5741,7 @@ describe('RewardsController', () => { it('throws authorization error when accounts state is undefined after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const localMockMessenger = { @@ -5852,7 +5821,7 @@ describe('RewardsController', () => { it('throws authorization error when accounts state is empty after 403', async () => { // Arrange const mock403Error = new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', + 'Authorization failed: 403', ); const localMockMessenger = { @@ -6878,13 +6847,9 @@ describe('RewardsController', () => { await expect( controller.getReferralDetails(mockSubscriptionId, mockSeasonId), ).rejects.toThrow('API connection failed'); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get referral details:', - 'API connection failed', - ); }); - it('logs and rethrows non-Error objects when API call fails', async () => { + it('rethrows non-Error objects when API call fails', async () => { controller = new RewardsController({ messenger: mockMessenger, state: { @@ -6913,10 +6878,6 @@ describe('RewardsController', () => { await expect( controller.getReferralDetails(mockSubscriptionId, mockSeasonId), ).rejects.toEqual(404); - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get referral details:', - '404', - ); }); }); @@ -14799,6 +14760,134 @@ describe('RewardsController', () => { 'boost-B-Y', ); }); + + it('reauthenticates and retries after 403 error on active boosts', async () => { + mockStoreSubscriptionToken.mockResolvedValue({ success: true }); + const seasonId = 'season-123'; + const subscriptionId = 'sub-456'; + const mock403Error = new AuthorizationFailedError( + 'Authorization failed: 403', + ); + const mockAccount = { + id: 'test-account-id', + address: '0x123', + name: 'Test Account', + type: 'eip155:eoa', + options: {}, + metadata: {}, + }; + const mockLoginResponse = { + subscription: { id: subscriptionId }, + }; + const mockBoosts = { + boosts: [ + { + id: 'boost-1', + name: 'Test Boost', + icon: { + lightModeUrl: 'https://example.com/light.png', + darkModeUrl: 'https://example.com/dark.png', + }, + boostBips: 500, + seasonLong: false, + backgroundColor: '#00FF00', + }, + ], + }; + + const localMockMessenger = { + subscribe: jest.fn(), + call: jest.fn(), + registerActionHandler: jest.fn(), + unregisterActionHandler: jest.fn(), + publish: jest.fn(), + clearEventSubscriptions: jest.fn(), + registerInitialEventPayload: jest.fn(), + unsubscribe: jest.fn(), + } as unknown as jest.Mocked; + + const testableController = new TestableRewardsController({ + messenger: localMockMessenger, + isDisabled: () => false, + }); + testableController.testUpdate((state) => { + state.activeAccount = { + subscriptionId, + account: 'eip155:1:0x123', + hasOptedIn: true, + perpsFeeDiscount: null, + lastPerpsDiscountRateFetched: null, + }; + state.accounts = {}; + state.subscriptions = { + [subscriptionId]: { + id: subscriptionId, + referralCode: 'REF123', + accounts: [], + }, + }; + }); + + let getBoostsCallCount = 0; + localMockMessenger.call.mockImplementation( + async (method, ..._args): Promise => { + if (method === 'RewardsDataService:getActivePointsBoosts') { + getBoostsCallCount++; + if (getBoostsCallCount === 1) { + return Promise.reject(mock403Error); + } + return mockBoosts; + } + if (method === 'AccountsController:getSelectedMultichainAccount') { + return mockAccount; + } + if (method === 'KeyringController:signPersonalMessage') { + return 'mock-signature'; + } + if (method === 'RewardsDataService:login') { + return mockLoginResponse; + } + return undefined; + }, + ); + + // Act + const result = await testableController.getActivePointsBoosts( + seasonId, + subscriptionId, + ); + + // Assert — retried successfully after reauth + expect(getBoostsCallCount).toBe(2); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('boost-1'); + expect(localMockMessenger.call).toHaveBeenCalledWith( + 'AccountsController:getSelectedMultichainAccount', + ); + expect(localMockMessenger.call).toHaveBeenCalledWith( + 'RewardsDataService:login', + expect.objectContaining({ + account: mockAccount.address, + }), + ); + }); + + it('does not retry on non-403 errors', async () => { + const seasonId = 'season-123'; + const subscriptionId = 'sub-456'; + const genericError = new Error('Network error'); + + mockMessenger.call.mockRejectedValue(genericError); + + await expect( + controller.getActivePointsBoosts(seasonId, subscriptionId), + ).rejects.toThrow('Network error'); + + // Should NOT attempt reauth for non-403 errors + expect(mockMessenger.call).not.toHaveBeenCalledWith( + 'AccountsController:getSelectedMultichainAccount', + ); + }); }); describe('getUnlockedRewards', () => { @@ -18619,11 +18708,6 @@ describe('RewardsController', () => { await expect( controller.getSnapshots(mockSeasonId, mockSubscriptionId), ).rejects.toThrow('Network timeout'); - - expect(mockLogger.log).toHaveBeenCalledWith( - 'RewardsController: Failed to get snapshots:', - 'Network timeout', - ); }); it('logs when fetching fresh snapshots data', async () => { diff --git a/app/core/Engine/controllers/rewards-controller/RewardsController.ts b/app/core/Engine/controllers/rewards-controller/RewardsController.ts index d4c8afac526..9c26aad66b7 100644 --- a/app/core/Engine/controllers/rewards-controller/RewardsController.ts +++ b/app/core/Engine/controllers/rewards-controller/RewardsController.ts @@ -292,6 +292,7 @@ export class RewardsController extends BaseController< #isBitcoinOptinEnabled: () => boolean; #isTronOptinEnabled: () => boolean; #isSnapshotsEnabled: () => boolean; + #reauthPromises: Map> = new Map(); /** * Calculate tier status and next tier information @@ -823,6 +824,113 @@ export class RewardsController extends BaseController< }); } + /** + * Re-authenticate the account linked to a given subscription. + * Resolves the matching InternalAccount and calls performSilentAuth. + */ + async #performReauthForSubscription(subscriptionId: string): Promise { + await removeSubscriptionToken(subscriptionId); + + if (this.state.activeAccount?.subscriptionId === subscriptionId) { + const account = await this.messenger.call( + 'AccountsController:getSelectedMultichainAccount', + ); + if (this.isOptInSupported(account as InternalAccount)) { + Logger.log( + 'RewardsController: Attempting reauth with active account after 403', + ); + const result = await this.performSilentAuth(account, false, false); + if (!result) { + throw new Error(`Reauth failed for subscription ${subscriptionId}`); + } + return; + } + } + + // Active account can't sign (e.g. hardware wallet) or doesn't match — + // find any software account linked to this subscription. + if (this.state.accounts && Object.values(this.state.accounts).length > 0) { + const allLinkedAccounts = Object.values(this.state.accounts).filter( + (acc) => acc.subscriptionId === subscriptionId, + ); + if (allLinkedAccounts.length > 0) { + const accounts = await this.messenger.call( + 'AccountsController:listMultichainAccounts', + ); + for (const linkedAccount of allLinkedAccounts) { + const intAccount = accounts.find((acc: InternalAccount) => { + const accCaipId = this.convertInternalAccountToCaipAccountId(acc); + return accCaipId === linkedAccount.account; + }); + if (intAccount && this.isOptInSupported(intAccount)) { + Logger.log( + 'RewardsController: Attempting reauth with linked account after 403', + ); + const result = await this.performSilentAuth( + intAccount as InternalAccount, + false, + false, + ); + if (!result) { + throw new Error( + `Reauth failed for subscription ${subscriptionId}`, + ); + } + return; + } + } + } + } + + throw new Error( + `No signable account found for subscription ${subscriptionId} to reauth`, + ); + } + + /** + * Wrap an authenticated async call with automatic 403 retry. + * On AuthorizationFailedError, coalesces concurrent reauths into a single + * performSilentAuth call, then retries the original function once. + */ + async #withAuthRetry( + fn: () => Promise, + subscriptionId: string, + ): Promise { + try { + return await fn(); + } catch (error) { + if (!(error instanceof AuthorizationFailedError)) throw error; + + if (!this.#reauthPromises.has(subscriptionId)) { + Logger.log( + 'RewardsController: 403 detected, initiating reauth for subscription', + subscriptionId, + ); + const promise = this.#performReauthForSubscription( + subscriptionId, + ).finally(() => { + this.#reauthPromises.delete(subscriptionId); + }); + this.#reauthPromises.set(subscriptionId, promise); + } else { + Logger.log( + 'RewardsController: 403 detected, reauth already in progress for subscription', + subscriptionId, + ); + } + + try { + await this.#reauthPromises.get(subscriptionId); + } catch (reauthError) { + this.invalidateSubscriptionCache(subscriptionId); + await this.invalidateSubscriptionAndAccounts(subscriptionId); + throw reauthError; + } + + return await fn(); + } + } + /** * Handle authentication triggers (account changes, keyring unlock) */ @@ -1589,16 +1697,19 @@ export class RewardsController extends BaseController< // If cursor is provided, always fetch fresh and do not touch cache if (params.cursor) { - const dto = await this.messenger.call( - 'RewardsDataService:getPointsEvents', - params, + const dto = await this.#withAuthRetry( + () => this.messenger.call('RewardsDataService:getPointsEvents', params), + params.subscriptionId, ); this.triggerBalanceUpdateIfNeeded(dto, params); return dto; } if (params.forceFresh) { - const dto = await this.getPointsEventsIfChanged(params); + const dto = await this.#withAuthRetry( + () => this.getPointsEventsIfChanged(params), + params.subscriptionId, + ); this.triggerBalanceUpdateIfNeeded(dto, params); return dto; } @@ -1625,8 +1736,8 @@ export class RewardsController extends BaseController< } : undefined; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh points events data via API call for seasonId & subscriptionId & type & page cursor', { @@ -1639,14 +1750,7 @@ export class RewardsController extends BaseController< const pointsEvents = await this.getPointsEventsIfChanged(params); this.triggerBalanceUpdateIfNeeded(pointsEvents, params); return pointsEvents; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get points events:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, params.subscriptionId), writeCache: (key, pointsEventsDto) => { this.update((state: RewardsControllerState) => { state.pointsEvents[key] = @@ -1720,11 +1824,14 @@ export class RewardsController extends BaseController< 'RewardsController: Getting fresh points events last updated for seasonId & subscriptionId', params, ); - const result = await this.messenger.call( - 'RewardsDataService:getPointsEventsLastUpdated', - params, + return this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:getPointsEventsLastUpdated', + params, + ), + params.subscriptionId, ); - return result; } /** @@ -2045,110 +2152,40 @@ export class RewardsController extends BaseController< if (!cached) return; return { payload: cached, lastFetched: cached.lastFetched }; }, - fetchFresh: async () => { - try { - Logger.log( - 'RewardsController: Fetching fresh season status data via API call for subscriptionId & seasonId', - subscriptionId, - seasonId, - ); - - // Now fetch season status (balance, currentTierId, etc.) - const seasonState = await this.messenger.call( - 'RewardsDataService:getSeasonStatus', - seasonId, - subscriptionId, - ); - - // Combine all data into SeasonStatusDto - const seasonStatus = this.convertToSeasonStatusDto( - season, - seasonState, - ); - return this.#convertSeasonStatusToSubscriptionState(seasonStatus); - } catch (error) { - if (error instanceof AuthorizationFailedError) { - // Attempt to reauth with a valid account. - try { - if (this.state.activeAccount?.subscriptionId === subscriptionId) { - const account = await this.messenger.call( - 'AccountsController:getSelectedMultichainAccount', - ); - Logger.log( - 'RewardsController: Attempting to reauth with a valid account after 403 error', - ); - await this.performSilentAuth(account, false, false); // try and auth. - } else if ( - this.state.accounts && - Object.values(this.state.accounts).length > 0 - ) { - const accountForSub = Object.values(this.state.accounts).find( - (acc) => acc.subscriptionId === subscriptionId, - ); - if (accountForSub) { - const accounts = await this.messenger.call( - 'AccountsController:listMultichainAccounts', - ); - const convertInternalAccountToCaipAccountId = - this.convertInternalAccountToCaipAccountId; - const intAccountForSub = accounts.find( - (acc: InternalAccount) => { - const accCaipId = - convertInternalAccountToCaipAccountId(acc); - return accCaipId === accountForSub.account; - }, - ); - if (intAccountForSub) { - Logger.log( - 'RewardsController: Attempting to reauth with any valid account after 403 error', - ); - await this.performSilentAuth( - intAccountForSub as InternalAccount, - false, - false, - ); - } - } - } - // Now fetch season status (balance, currentTierId, etc.) - const seasonState = await this.messenger.call( - 'RewardsDataService:getSeasonStatus', - season.id, - subscriptionId, - ); + fetchFresh: async () => + this.#withAuthRetry(async () => { + try { + Logger.log( + 'RewardsController: Fetching fresh season status data via API call for subscriptionId & seasonId', + subscriptionId, + seasonId, + ); - // Combine all data into SeasonStatusDto - const seasonStatus = this.convertToSeasonStatusDto( - season, - seasonState, - ); + const seasonState = await this.messenger.call( + 'RewardsDataService:getSeasonStatus', + seasonId, + subscriptionId, + ); - Logger.log( - 'RewardsController: Successfully fetched season status after reauth', - ); - return this.#convertSeasonStatusToSubscriptionState(seasonStatus); - } catch { - Logger.log( - 'RewardsController: Failed to reauth with a valid account after 403 error', - error instanceof Error ? error.message : String(error), - ); - this.invalidateSubscriptionCache(subscriptionId); - await this.invalidateSubscriptionAndAccounts(subscriptionId); + const seasonStatus = this.convertToSeasonStatusDto( + season, + seasonState, + ); + return this.#convertSeasonStatusToSubscriptionState(seasonStatus); + } catch (error) { + if (error instanceof SeasonNotFoundError) { + this.update((state: RewardsControllerState) => { + state.seasons = {}; + }); throw error; } - } else if (error instanceof SeasonNotFoundError) { - this.update((state: RewardsControllerState) => { - state.seasons = {}; - }); + Logger.log( + 'RewardsController: Failed to get season status:', + error instanceof Error ? error.message : String(error), + ); throw error; } - Logger.log( - 'RewardsController: Failed to get season status:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, subscriptionSeasonStatus) => { this.update((state: RewardsControllerState) => { // Update season status with composite key @@ -2234,8 +2271,8 @@ export class RewardsController extends BaseController< if (!cached) return; return { payload: cached, lastFetched: cached.lastFetched }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh referral details data via API call for', { subscriptionId, seasonId }, @@ -2252,14 +2289,7 @@ export class RewardsController extends BaseController< referredByCode: referralDetails.referredByCode, lastFetched: Date.now(), }; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get referral details:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.subscriptionReferralDetails[key] = payload; @@ -2701,9 +2731,13 @@ export class RewardsController extends BaseController< } try { - const response = await this.messenger.call( - 'RewardsDataService:validateBonusCode', - code, + const response = await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:validateBonusCode', + code, + subscriptionId, + ), subscriptionId, ); return response.valid; @@ -3081,8 +3115,8 @@ export class RewardsController extends BaseController< } // Call the opt-out endpoint - const result = await this.messenger.call( - 'RewardsDataService:optOut', + const result = await this.#withAuthRetry( + () => this.messenger.call('RewardsDataService:optOut', subscriptionId), subscriptionId, ); @@ -3135,8 +3169,8 @@ export class RewardsController extends BaseController< lastFetched: cachedActiveBoosts.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh active boosts data via API call for subscriptionId & seasonId', subscriptionId, @@ -3148,14 +3182,7 @@ export class RewardsController extends BaseController< subscriptionId, ); return response.boosts; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get active points boosts:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.activeBoosts[key] = { @@ -3195,8 +3222,8 @@ export class RewardsController extends BaseController< lastFetched: cachedUnlockedRewards.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh unlocked rewards data via API call for subscriptionId & seasonId', subscriptionId, @@ -3208,14 +3235,7 @@ export class RewardsController extends BaseController< subscriptionId, )) as RewardDto[]; return response || []; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get unlocked rewards:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.unlockedRewards[key] = { @@ -3257,8 +3277,8 @@ export class RewardsController extends BaseController< lastFetched: cachedSnapshots.lastFetched, }; }, - fetchFresh: async () => { - try { + fetchFresh: async () => + this.#withAuthRetry(async () => { Logger.log( 'RewardsController: Fetching fresh snapshots data via API call for seasonId', seasonId, @@ -3269,14 +3289,7 @@ export class RewardsController extends BaseController< subscriptionId, )) as SnapshotDto[]; return response || []; - } catch (error) { - Logger.log( - 'RewardsController: Failed to get snapshots:', - error instanceof Error ? error.message : String(error), - ); - throw error; - } - }, + }, subscriptionId), writeCache: (key, payload) => { this.update((state: RewardsControllerState) => { state.snapshots[key] = { @@ -3306,11 +3319,15 @@ export class RewardsController extends BaseController< throw new Error('Rewards are not enabled'); } try { - await this.messenger.call( - 'RewardsDataService:claimReward', - rewardId, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:claimReward', + rewardId, + subscriptionId, + dto, + ), subscriptionId, - dto, ); // Invalidate cache for the active subscription @@ -3349,8 +3366,12 @@ export class RewardsController extends BaseController< } try { - const result = await this.messenger.call( - 'RewardsDataService:getSeasonOneLineaRewardTokens', + const result = await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:getSeasonOneLineaRewardTokens', + subscriptionId, + ), subscriptionId, ); return result; @@ -3380,9 +3401,13 @@ export class RewardsController extends BaseController< } try { - await this.messenger.call( - 'RewardsDataService:applyReferralCode', - { referralCode }, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:applyReferralCode', + { referralCode }, + subscriptionId, + ), subscriptionId, ); @@ -3419,9 +3444,13 @@ export class RewardsController extends BaseController< } try { - await this.messenger.call( - 'RewardsDataService:applyBonusCode', - { bonusCode }, + await this.#withAuthRetry( + () => + this.messenger.call( + 'RewardsDataService:applyBonusCode', + { bonusCode }, + subscriptionId, + ), subscriptionId, ); diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts index db5758761e5..b197e6666e3 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.test.ts @@ -1138,6 +1138,121 @@ describe('RewardsDataService', () => { }); }); + describe('centralized 403 detection in makeRequest', () => { + it('throws AuthorizationFailedError for any endpoint returning 403', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getActivePointsBoosts('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + }); + + it('throws AuthorizationFailedError with status in message', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getActivePointsBoosts('season-1', 'sub-1'), + ).rejects.toThrow('Authorization failed: 403'); + }); + + it('does not throw AuthorizationFailedError for non-403 errors', async () => { + const mockResponse = { + ok: false, + status: 401, + json: jest.fn().mockResolvedValue({ message: 'Unauthorized' }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.getReferralDetails('season-1', 'sub-1'), + ).rejects.toThrow('Get referral details failed: 401'); + }); + + it('throws AuthorizationFailedError for 403 on different endpoints', async () => { + const mockResponse = { + ok: false, + status: 403, + } as unknown as Response; + + mockFetch.mockResolvedValue(mockResponse); + await expect( + service.getUnlockedRewards('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + + mockFetch.mockResolvedValue(mockResponse); + await expect( + service.getSnapshots('season-1', 'sub-1'), + ).rejects.toBeInstanceOf(AuthorizationFailedError); + + mockFetch.mockResolvedValue(mockResponse); + await expect(service.optOut('sub-1')).rejects.toBeInstanceOf( + AuthorizationFailedError, + ); + }); + + it('does not throw AuthorizationFailedError for 403 on unauthenticated endpoints', async () => { + const mockResponse = { + ok: false, + status: 403, + json: jest.fn().mockResolvedValue({ message: 'Forbidden' }), + } as unknown as Response; + mockFetch.mockResolvedValue(mockResponse); + + await expect( + service.estimatePoints({ + activityType: 'SWAP', + account: 'eip155:1:0x123', + activityContext: { + swapContext: { + srcAsset: { + id: 'eip155:1/slip44:60', + amount: '1000000000000000000', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4500000000', + }, + feeAsset: { + id: 'eip155:1/slip44:60', + amount: '5000000000000000', + }, + }, + }, + }), + ).rejects.toThrow('Points estimation failed: 403'); + await expect( + service.estimatePoints({ + activityType: 'SWAP', + account: 'eip155:1:0x123', + activityContext: { + swapContext: { + srcAsset: { + id: 'eip155:1/slip44:60', + amount: '1000000000000000000', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4500000000', + }, + feeAsset: { + id: 'eip155:1/slip44:60', + amount: '5000000000000000', + }, + }, + }, + }), + ).rejects.not.toBeInstanceOf(AuthorizationFailedError); + }); + }); + const mockSeasonStateResponse: SeasonStateDto = { balance: 1000, currentTierId: 'tier-gold', @@ -1224,10 +1339,10 @@ describe('RewardsDataService', () => { ).rejects.toThrow('Get season state failed: 404'); }); - it('throws AuthorizationFailedError when rewards authorization fails', async () => { + it('throws AuthorizationFailedError when server returns 403', async () => { const mockResponse = { ok: false, - status: 401, + status: 403, json: jest.fn().mockResolvedValue({ message: 'Rewards authorization failed', }), @@ -1244,25 +1359,7 @@ describe('RewardsDataService', () => { expect(caughtError).toBeInstanceOf(AuthorizationFailedError); const authError = caughtError as AuthorizationFailedError; expect(authError.name).toBe('AuthorizationFailedError'); - expect(authError.message).toBe( - 'Rewards authorization failed. Please login and try again.', - ); - }); - - it('detects authorization failure when message contains the phrase', async () => { - const mockResponse = { - ok: false, - status: 403, - json: jest.fn().mockResolvedValue({ - message: - 'Some other error: Rewards authorization failed due to expiry', - }), - } as unknown as Response; - mockFetch.mockResolvedValue(mockResponse); - - await expect( - service.getSeasonStatus(mockSeasonId, mockSubscriptionId), - ).rejects.toBeInstanceOf(AuthorizationFailedError); + expect(authError.message).toBe('Authorization failed: 403'); }); it('throws SeasonNotFoundError when season is not found', async () => { diff --git a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts index 2468209ca25..90397c9fc52 100644 --- a/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts +++ b/app/core/Engine/controllers/rewards-controller/services/rewards-data-service.ts @@ -526,6 +526,13 @@ export class RewardsDataService { }); clearTimeout(timeoutId); + + if (response.status === 403 && subscriptionId) { + throw new AuthorizationFailedError( + `Authorization failed: ${response.status}`, + ); + } + return response; } catch (error) { clearTimeout(timeoutId); @@ -787,11 +794,6 @@ export class RewardsDataService { if (!response.ok) { const errorData = await response.json(); - if (errorData?.message?.includes('Rewards authorization failed')) { - throw new AuthorizationFailedError( - 'Rewards authorization failed. Please login and try again.', - ); - } if (errorData?.message?.includes('Season not found')) { throw new SeasonNotFoundError( From 95a4cb348c71cff89d759e588056918c4d3e4590 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Wed, 4 Mar 2026 16:27:31 +0700 Subject: [PATCH 04/10] fix(token-details): Use scoped account for EVM receive address after non-EVM network switch (#26965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** When switching from a Non-EVM network (e.g. Solana, Bitcoin) to All Popular Networks, the Receive QR modal in EVM token details was displaying the Non-EVM account address instead of the correct EVM (0x) address. The root cause was in `useTokenActions.ts`: the `onReceive` handler only called `getAccountByScope` for non-EVM tokens, falling back to `selectedInternalAccount` for EVM tokens. Since `selectedInternalAccount` is the globally selected account and remains stale from the previous non-EVM network selection, the wrong address was passed to the QR modal. The fix aligns `onReceive` with the pattern already used correctly in `useTokenTransactions.ts` — always resolving the account via `getAccountByScope` with the CAIP-formatted chain ID, falling back to `selectedInternalAccount` only if no scoped account is found. ## **Changelog** CHANGELOG entry: Fixed a bug where the Receive address in EVM token details showed a Non-EVM address after switching from a Non-EVM network. ## **Related issues** Fixes: https://github.com/MetaMask/metamask-mobile/issues/26793 ## **Manual testing steps** ```gherkin Feature: Receive address in token details Scenario: user switches from a Non-EVM network to an EVM network and opens Receive Given the user has both a Solana account and an Ethereum account in the same group And the user is viewing a Solana token and opens the Receive modal And the user navigates back and switches to All Popular Networks When the user opens an Ethereum token details and taps Receive Then the QR modal should display the correct 0x Ethereum address And the address should NOT be a base58 Solana address ``` ## **Screenshots/Recordings** `~` ### **Before** https://github.com/user-attachments/assets/0a26d5be-fbc6-4275-b27a-ff2c094e395f ### **After** https://github.com/user-attachments/assets/efee1b40-26cc-438c-9fd6-7621443486d4 ## **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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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** > Touches the address-selection logic used when displaying the Receive QR modal; a wrong scope/CAIP conversion could show an incorrect address for some networks. The change is small and localized, with a fallback to the previously selected internal account. > > **Overview** > **Fixes a Receive-address scoping bug in Token Details.** The `onReceive` handler now always resolves the account via `selectSelectedInternalAccountByScope` using a CAIP-formatted `token.chainId`, falling back to `selectedInternalAccount` only if no scoped account exists. > > This prevents the Receive QR sheet from showing a stale non-EVM address when opening an EVM token after switching networks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 4202b646b31a18eca615d2fad9b088ebed69ad08. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- app/components/UI/TokenDetails/hooks/useTokenActions.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 0d7d12d1c3c..d49e4b2bf65 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -176,10 +176,11 @@ export const useTokenActions = ({ location: ActionLocation.ASSET_DETAILS, }); - const accountForChain = - isNonEvmToken && token.chainId - ? getAccountByScope(token.chainId as CaipChainId) - : selectedInternalAccount; + const accountForChain = token.chainId + ? (getAccountByScope( + formatChainIdToCaip(token.chainId as Hex) as CaipChainId, + ) ?? selectedInternalAccount) + : selectedInternalAccount; const addressForChain = accountForChain?.address; From e5e4c3ebb6f33b713d2555dafc425ca86d7b0c5f Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Wed, 4 Mar 2026 10:43:56 +0100 Subject: [PATCH 05/10] feat: generic advanced charts component (#26459) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Introduces a reusable, generic `AdvancedChart` component built on [TradingView Advanced Charts](https://www.tradingview.com/charting-library-docs/latest/getting_started/) rendered inside a WebView. This component is designed to be consumed by multiple features (Token Details, Perps, etc.) with a composable props API, each consumer uses only the props it needs. This PR is just for testing this new component: https://github.com/MetaMask/metamask-mobile/pull/26465 ### What's included **Core component (`AdvancedChart.tsx`)** - WebView-based TradingView charting widget with full candlestick/OHLCV support - Composable props API: `ohlcvData`, `indicators`, `positionLines`, `showVolume`, `chartType`, etc. - Imperative ref handle for one-off actions (`addIndicator`, `removeIndicator`, `setChartType`, `reset`) - Bidirectional message protocol between React Native and WebView (`RNToWebViewMessage` / `WebViewToRNMessage`) - Error handling with retry-on-recovery and loading states - Dynamic resolution detection: automatically adapts bar width based on incoming data interval **WebView logic (`webview/chartLogic.js`)** - Custom TradingView datafeed implementation (`onReady`, `getBars`, `subscribeBars`, `unsubscribeBars`) - Widget lifecycle management: creates, destroys, and recreates the TradingView widget when the data resolution changes (e.g., switching from 1D to 1H) - Volume study with themed colors - Technical indicator support (MACD, RSI, MA200) via `createStudy` / `removeStudy` - Position lines overlay (entry, take-profit, stop-loss, liquidation) for Perps use case - Real-time bar updates via `realtimeCallback` - Crosshair tracking with bar-snapping for OHLC data forwarding - All unnecessary TradingView UI elements disabled via `disabled_features` **HTML template (`AdvancedChartTemplate.ts`)** - Generates WebView HTML with injected theme colors and feature flags - Configurable `CHARTING_LIBRARY_URL` for local dev (`localhost:8000`) or S3 deployment **Time range selector (`TimeRangeSelector.tsx`)** - Time range options: 1H, 1D, 1W, 1M, YTD, ALL - Each range maps to a Hyperliquid candle interval and count (e.g., 1H → 1m candles × 60) - Exports `TIME_RANGE_CONFIGS` for consumer data fetching **Indicator toggle (`IndicatorToggle.tsx`)** - Toggle buttons for MACD, RSI, MA200 technical indicators - Designed for Token Details chart header ## **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** > Introduces a new WebView-based chart surface that loads the TradingView library from a configurable CDN and bridges messages between JS and React Native, which carries moderate risk around WebView security/CSP correctness and runtime stability. > > **Overview** > Adds a reusable `AdvancedChart` component that renders TradingView Advanced Charts in a `WebView`, with a typed RN↔WebView message protocol to sync OHLCV data, realtime ticks, indicators, chart type, volume visibility, and optional position-line overlays. > > Introduces a generated WebView script pipeline (`webview/chartLogic.js` → `chartLogicString.ts` via `syncChartLogic.js`) plus an HTML template (`AdvancedChartTemplate.ts`) that injects theme/feature config, CSP, and a configurable charting-library base URL (`MM_CHARTING_LIBRARY_URL`). > > Adds a `TimeRangeSelector` utility component with predefined range→interval/count mappings, updates `.eslintignore` for the WebView scripts, and includes unit tests covering chart message/ref behavior and the selector. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3785ec118c29361cfd4260fe91207784e41843eb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .eslintignore | 3 + .js.env.example | 6 + .../AdvancedChart/AdvancedChart.styles.ts | 44 + .../UI/Charts/AdvancedChart/AdvancedChart.tsx | 366 ++++++++ .../AdvancedChart/AdvancedChart.types.ts | 352 +++++++ .../AdvancedChart/AdvancedChartTemplate.ts | 136 +++ .../AdvancedChart/TimeRangeSelector.tsx | 112 +++ .../__tests__/AdvancedChart.test.tsx | 437 +++++++++ .../__tests__/TimeRangeSelector.test.tsx | 107 +++ .../AdvancedChart/webview/chartLogic.js | 875 +++++++++++++++++ .../AdvancedChart/webview/chartLogicString.ts | 880 ++++++++++++++++++ .../UI/Charts/AdvancedChart/webview/index.ts | 1 + .../AdvancedChart/webview/syncChartLogic.js | 31 + 13 files changed, 3350 insertions(+) create mode 100644 app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts create mode 100644 app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx create mode 100644 app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts create mode 100644 app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts create mode 100644 app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx create mode 100644 app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx create mode 100644 app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx create mode 100644 app/components/UI/Charts/AdvancedChart/webview/chartLogic.js create mode 100644 app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts create mode 100644 app/components/UI/Charts/AdvancedChart/webview/index.ts create mode 100644 app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js diff --git a/.eslintignore b/.eslintignore index f654ed55c5e..15edab97f27 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,5 +1,8 @@ /scripts/inpage-bridge /app/core/InpageBridgeWeb3.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js +/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts +/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js /app/util/blockies.js __snapshots__ android diff --git a/.js.env.example b/.js.env.example index a15cddcf5c8..5bb6f3c81cd 100644 --- a/.js.env.example +++ b/.js.env.example @@ -172,6 +172,12 @@ export ENABLE_WHY_DID_YOU_RENDER="false" # Rewards API URL export REWARDS_API_URL="" +## Advanced Charts (TradingView charting library CDN) +# Production: CloudFront distribution URL (trailing slash required) +# Development: local http-server, e.g. http://localhost:8000/ +# Leave empty to use the default S3 origin fallback +export MM_CHARTING_LIBRARY_URL="" + ## Perps export MM_PERPS_ENABLED="true" diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts new file mode 100644 index 00000000000..c918d14a988 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.styles.ts @@ -0,0 +1,44 @@ +import { StyleSheet } from 'react-native'; +import type { Theme } from '../../../../util/theme/models'; + +export const DEFAULT_CHART_HEIGHT = 400; + +const styleSheet = (params: { theme: Theme; vars: { height: number } }) => + StyleSheet.create({ + container: { + width: '100%', + height: params.vars.height, + backgroundColor: params.theme.colors.background.default, + }, + webview: { + flex: 1, + backgroundColor: params.theme.colors.background.default, + }, + loadingContainer: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: params.theme.colors.background.default, + }, + loadingText: { + marginTop: 12, + color: params.theme.colors.text.muted, + }, + errorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + backgroundColor: params.theme.colors.background.default, + }, + errorText: { + color: params.theme.colors.error.default, + textAlign: 'center', + }, + }); + +export default styleSheet; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx new file mode 100644 index 00000000000..54af667d48d --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.tsx @@ -0,0 +1,366 @@ +import React, { + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, + forwardRef, +} from 'react'; +import { View, ActivityIndicator } from 'react-native'; +import { WebView, WebViewMessageEvent } from '@metamask/react-native-webview'; +import { Text, TextVariant } from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet, { DEFAULT_CHART_HEIGHT } from './AdvancedChart.styles'; +import { + createAdvancedChartTemplate, + CHARTING_LIBRARY_BASE_URL, +} from './AdvancedChartTemplate'; +import { + ChartType, + DEFAULT_DISABLED_FEATURES, + parseWebViewMessage, + type AdvancedChartProps, + type AdvancedChartRef, + type IndicatorType, + type OHLCVBar, + type RNToWebViewMessage, +} from './AdvancedChart.types'; + +/** + * Generic TradingView Advanced Chart component. + * + * Renders a professional charting widget inside a WebView. + * Designed to be consumed by multiple features (Token Details, Perps, etc.) + * with a composable props API -- each consumer uses only the props it needs. + * + * ATTRIBUTION NOTICE: + * TradingView Advanced Charts (TM) + * Copyright (c) 2025 TradingView, Inc. https://www.tradingview.com/ + */ +const AdvancedChart = forwardRef( + ( + { + ohlcvData, + height = DEFAULT_CHART_HEIGHT, + realtimeBar, + onRequestMoreHistory, + indicators = [], + positionLines, + chartType, + showVolume = false, + enableDrawingTools = false, + disabledFeatures = DEFAULT_DISABLED_FEATURES, + onChartReady, + onError, + onCrosshairMove, + isLoading = false, + }, + ref, + ) => { + const { styles, theme } = useStyles(styleSheet, { + height, + } as { height: number }); + const webViewRef = useRef(null); + const [chartReadyCount, setChartReadyCount] = useState(0); + const isChartReady = chartReadyCount > 0; + const [webViewError, setWebViewError] = useState(null); + + const activeIndicatorsRef = useRef>(new Set()); + const [webViewLoaded, setWebViewLoaded] = useState(false); + const prevPositionLinesRef = useRef(positionLines); + const prevChartTypeRef = useRef(chartType); + const prevShowVolumeRef = useRef(showVolume); + + const htmlContent = useMemo( + () => + createAdvancedChartTemplate(theme, { + enableDrawingTools, + showVolume, + disabledFeatures, + }), + [theme, enableDrawingTools, showVolume, disabledFeatures], + ); + + // Reset all chart state when the WebView reloads due to htmlContent changes + useEffect(() => { + setChartReadyCount(0); + setWebViewLoaded(false); + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + }, [htmlContent]); // eslint-disable-line react-hooks/exhaustive-deps + + // ---- Helpers ---- + + const postMessage = useCallback((message: RNToWebViewMessage) => { + if (webViewRef.current) { + webViewRef.current.postMessage(JSON.stringify(message)); + } + }, []); + + const sendOHLCVData = useCallback( + (data: OHLCVBar[]) => { + postMessage({ + type: 'SET_OHLCV_DATA', + payload: { data }, + }); + }, + [postMessage], + ); + + const addIndicator = useCallback( + (indicator: IndicatorType, inputs?: Record) => { + if (!isChartReady) return; + postMessage({ + type: 'ADD_INDICATOR', + payload: { name: indicator, inputs }, + }); + }, + [isChartReady, postMessage], + ); + + const removeIndicator = useCallback( + (indicator: IndicatorType) => { + if (!isChartReady) return; + postMessage({ + type: 'REMOVE_INDICATOR', + payload: { name: indicator }, + }); + }, + [isChartReady, postMessage], + ); + + const setChartTypeInternal = useCallback( + (type: ChartType) => { + if (!isChartReady) return; + postMessage({ + type: 'SET_CHART_TYPE', + payload: { type }, + }); + }, + [isChartReady, postMessage], + ); + + // ---- WebView message handling ---- + + const handleMessage = useCallback( + (event: WebViewMessageEvent) => { + let raw; + try { + raw = JSON.parse(event.nativeEvent.data); + } catch { + return; + } + + const message = parseWebViewMessage(raw); + if (!message) return; + + switch (message.type) { + case 'CHART_READY': + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + setChartReadyCount((c) => c + 1); + setWebViewError(null); + onChartReady?.(); + break; + + case 'INDICATOR_ADDED': + activeIndicatorsRef.current.add(message.payload.name); + break; + + case 'INDICATOR_REMOVED': + activeIndicatorsRef.current.delete(message.payload.name); + break; + + case 'CROSSHAIR_MOVE': + onCrosshairMove?.(message.payload.data); + break; + + case 'NEED_MORE_HISTORY': + onRequestMoreHistory?.(message.payload); + break; + + case 'ERROR': + if (!isChartReady) { + setWebViewError(message.payload.message); + } + onError?.(message.payload.message); + break; + + case 'DEBUG': + break; + + default: + break; + } + }, + [ + isChartReady, + showVolume, + onChartReady, + onError, + onCrosshairMove, + onRequestMoreHistory, + ], + ); + + const handleWebViewError = useCallback( + (syntheticEvent: { nativeEvent: { description: string } }) => { + const { description } = syntheticEvent.nativeEvent; + setWebViewError(description); + onError?.(description); + }, + [onError], + ); + + const handleLoadEnd = useCallback(() => { + setWebViewLoaded(true); + }, []); + + // ---- Ref API ---- + + useImperativeHandle( + ref, + () => ({ + addIndicator, + removeIndicator, + setChartType: setChartTypeInternal, + reset: () => { + setChartReadyCount(0); + setWebViewLoaded(false); + setWebViewError(null); + activeIndicatorsRef.current.clear(); + prevPositionLinesRef.current = undefined; + prevChartTypeRef.current = undefined; + prevShowVolumeRef.current = showVolume; + webViewRef.current?.reload(); + }, + }), + [addIndicator, removeIndicator, setChartTypeInternal, showVolume], + ); + + // ---- Declarative prop syncing ---- + + useEffect(() => { + if (ohlcvData.length > 0 && webViewLoaded) { + sendOHLCVData(ohlcvData); + } + }, [ohlcvData, webViewLoaded, sendOHLCVData]); + + // Forward real-time bar updates to WebView + useEffect(() => { + if (!isChartReady || !realtimeBar) return; + postMessage({ + type: 'REALTIME_UPDATE', + payload: { bar: realtimeBar }, + }); + }, [realtimeBar, isChartReady, postMessage]); + + // Sync indicators prop (depends on chartReadyCount to re-fire on chart recreation) + useEffect(() => { + if (chartReadyCount === 0) return; + + const currentIndicators = new Set(indicators); + const active = activeIndicatorsRef.current; + + indicators.forEach((indicator) => { + if (!active.has(indicator)) { + addIndicator(indicator); + } + }); + + active.forEach((indicator) => { + if (!currentIndicators.has(indicator)) { + removeIndicator(indicator); + } + }); + }, [indicators, chartReadyCount, addIndicator, removeIndicator]); + + // Sync positionLines prop + useEffect(() => { + if (chartReadyCount === 0) return; + if (positionLines === prevPositionLinesRef.current) return; + prevPositionLinesRef.current = positionLines; + + postMessage({ + type: 'SET_POSITION_LINES', + payload: { position: positionLines ?? null }, + }); + }, [positionLines, chartReadyCount, postMessage]); + + // Sync chartType prop + useEffect(() => { + if (chartReadyCount === 0 || chartType === undefined) return; + if (chartType === prevChartTypeRef.current) return; + prevChartTypeRef.current = chartType; + setChartTypeInternal(chartType); + }, [chartType, chartReadyCount, setChartTypeInternal]); + + // Sync showVolume prop + useEffect(() => { + if (chartReadyCount === 0) return; + if (showVolume === prevShowVolumeRef.current) return; + prevShowVolumeRef.current = showVolume; + + postMessage({ + type: 'TOGGLE_VOLUME', + payload: { visible: showVolume }, + }); + }, [showVolume, chartReadyCount, postMessage]); + + // ---- Render ---- + + if (webViewError) { + return ( + + + Failed to load chart: {webViewError} + + + ); + } + + return ( + + + + {(isLoading || !isChartReady) && ( + + + + Loading chart... + + + )} + + ); + }, +); + +AdvancedChart.displayName = 'AdvancedChart'; + +export default AdvancedChart; diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts new file mode 100644 index 00000000000..8dbfc56bf47 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChart.types.ts @@ -0,0 +1,352 @@ +// ============================================ +// OHLCV data types +// ============================================ + +/** + * OHLCV bar data structure for TradingView Advanced Charts + */ +export interface OHLCVBar { + /** Unix timestamp in milliseconds */ + time: number; + /** Opening price */ + open: number; + /** Highest price */ + high: number; + /** Lowest price */ + low: number; + /** Closing price */ + close: number; + /** Trading volume */ + volume: number; +} + +/** + * Any TradingView study name is accepted. The three presets ('MACD', 'RSI', + * 'MA200') get built-in parameter defaults in chartLogic.js; all other strings + * are forwarded to `createStudy` as-is with optional `inputs` from the payload. + */ +export type IndicatorType = string; + +/** + * TradingView widget features disabled by default. + * + * These defaults are optimized for the Token Details mobile UX: a clean, + * minimal chart with no header chrome, search, or toolbars. Consumers + * needing TradingView's native UI (e.g. Perps with full indicator picker, + * timeframes toolbar, or symbol search) can pass a custom list via the + * `disabledFeatures` prop to opt back in selectively. + */ +export const DEFAULT_DISABLED_FEATURES: string[] = [ + 'use_localstorage_for_settings', + 'header_widget', + 'timeframes_toolbar', + 'edit_buttons_in_legend', + 'control_bar', + 'border_around_the_chart', + 'header_symbol_search', + 'header_settings', + 'header_compare', + 'header_undo_redo', + 'header_screenshot', + 'header_fullscreen_button', + 'legend_context_menu', + 'symbol_search_hot_key', + 'symbol_info', + 'legend_widget', + 'display_market_status', + 'scales_context_menu', + 'pane_context_menu', + 'create_volume_indicator_by_default', + 'main_series_scale_menu', + 'go_to_date', +]; + +/** + * Position side for long/short position shapes + */ +export type PositionSide = 'long' | 'short'; + +/** + * Position lines to render on the chart (Perps-style dashed horizontal lines). + * Only lines with defined values are rendered. + */ +export interface PositionLines { + side: PositionSide; + entryPrice: number; + currentPrice?: number; + takeProfitPrice?: number; + stopLossPrice?: number; + liquidationPrice?: number; +} + +/** + * Crosshair OHLC data forwarded from the WebView when the user + * scrubs over the chart. Mirrors the Perps OhlcData contract. + */ +export interface CrosshairData { + time: number; + open: number; + high: number; + low: number; + close: number; + volume?: number; +} + +/** + * Chart type constants matching TradingView SeriesType. + * Uses as-const object instead of enum to avoid numeric enum pitfalls + * (reverse lookups, runtime code, opaque values in bridge messages). + */ +export const ChartType = { + Candles: 1, + Line: 2, +} as const; + +export type ChartType = (typeof ChartType)[keyof typeof ChartType]; + +// ============================================ +// Message protocol: React Native <-> WebView +// ============================================ + +export type RNToWebViewMessageType = + | 'SET_OHLCV_DATA' + | 'ADD_INDICATOR' + | 'REMOVE_INDICATOR' + | 'SET_CHART_TYPE' + | 'SET_POSITION_LINES' + | 'REALTIME_UPDATE' + | 'TOGGLE_VOLUME'; + +export type WebViewToRNMessageType = + | 'CHART_READY' + | 'INDICATOR_ADDED' + | 'INDICATOR_REMOVED' + | 'CROSSHAIR_MOVE' + | 'NEED_MORE_HISTORY' + | 'ERROR' + | 'DEBUG'; + +export interface SetOHLCVDataPayload { + data: OHLCVBar[]; +} + +export interface AddIndicatorPayload { + name: IndicatorType; + /** Custom TradingView study inputs (e.g. { in_0: 14 }). Used for non-preset studies. */ + inputs?: Record; +} + +export interface RemoveIndicatorPayload { + name: IndicatorType; +} + +export interface SetChartTypePayload { + type: ChartType; +} + +export interface SetPositionLinesPayload { + position: PositionLines | null; +} + +export interface RealtimeUpdatePayload { + bar: OHLCVBar; +} + +export interface ToggleVolumePayload { + visible: boolean; +} + +export type RNToWebViewMessage = + | { type: 'SET_OHLCV_DATA'; payload: SetOHLCVDataPayload } + | { type: 'ADD_INDICATOR'; payload: AddIndicatorPayload } + | { type: 'REMOVE_INDICATOR'; payload: RemoveIndicatorPayload } + | { type: 'SET_CHART_TYPE'; payload: SetChartTypePayload } + | { type: 'SET_POSITION_LINES'; payload: SetPositionLinesPayload } + | { type: 'REALTIME_UPDATE'; payload: RealtimeUpdatePayload } + | { type: 'TOGGLE_VOLUME'; payload: ToggleVolumePayload }; + +export interface IndicatorAddedPayload { + name: IndicatorType; + id: string; +} + +export interface IndicatorRemovedPayload { + name: IndicatorType; +} + +export interface CrosshairMovePayload { + data: CrosshairData | null; +} + +export interface NeedMoreHistoryPayload { + oldestTimestamp: number; +} + +export interface ErrorPayload { + message: string; + code?: string; +} + +export type WebViewToRNMessage = + | { type: 'CHART_READY' } + | { type: 'INDICATOR_ADDED'; payload: IndicatorAddedPayload } + | { type: 'INDICATOR_REMOVED'; payload: IndicatorRemovedPayload } + | { type: 'CROSSHAIR_MOVE'; payload: CrosshairMovePayload } + | { type: 'NEED_MORE_HISTORY'; payload: NeedMoreHistoryPayload } + | { type: 'ERROR'; payload: ErrorPayload } + | { type: 'DEBUG' }; + +// ============================================ +// Message parsing / runtime narrowing +// ============================================ + +function isIndicatorType(value: unknown): value is IndicatorType { + return typeof value === 'string' && value.length > 0; +} + +/** + * Runtime narrower for messages arriving from the WebView over postMessage. + * Returns a typed WebViewToRNMessage if valid, or null for malformed data. + */ +export function parseWebViewMessage(raw: unknown): WebViewToRNMessage | null { + if (typeof raw !== 'object' || raw === null) return null; + + const { type, payload } = raw as { type: unknown; payload: unknown }; + if (typeof type !== 'string') return null; + + const obj = ( + typeof payload === 'object' && payload !== null ? payload : {} + ) as Record; + + switch (type) { + case 'CHART_READY': + case 'DEBUG': + return { type }; + + case 'NEED_MORE_HISTORY': + return { + type, + payload: { + oldestTimestamp: + typeof obj.oldestTimestamp === 'number' ? obj.oldestTimestamp : 0, + }, + }; + + case 'INDICATOR_ADDED': + if (isIndicatorType(obj.name) && typeof obj.id === 'string') { + return { type, payload: { name: obj.name, id: obj.id } }; + } + return null; + + case 'INDICATOR_REMOVED': + if (isIndicatorType(obj.name)) { + return { type, payload: { name: obj.name } }; + } + return null; + + case 'CROSSHAIR_MOVE': + return { + type, + payload: { + data: + typeof obj.data === 'object' && obj.data !== null + ? (obj.data as CrosshairData) + : null, + }, + }; + + case 'ERROR': + if (typeof obj.message === 'string') { + return { + type, + payload: { + message: obj.message, + ...(typeof obj.code === 'string' ? { code: obj.code } : {}), + }, + }; + } + return null; + + default: + return null; + } +} + +// ============================================ +// Component props and ref +// ============================================ + +/** + * Generic AdvancedChart component props. + * + * Composable API: each consumer uses only the props it needs. + * - Token Details: ohlcvData, indicators, chartType + * - Perps: ohlcvData, positionLines, onRequestMoreHistory, onRealtimeUpdate, onCrosshairMove + */ +export interface AdvancedChartProps { + /** OHLCV data to display (required) */ + ohlcvData: OHLCVBar[]; + /** Chart height in pixels */ + height?: number; + + /** Latest bar for real-time streaming (Perps). When this changes the WebView receives a tick. */ + realtimeBar?: OHLCVBar; + /** + * Called when the user scrolls to the left edge and more history is needed. + * Receives the oldest bar timestamp (ms) currently held by the chart so + * consumers can use it directly as the `endTime` for their next fetch, + * without having to independently track their own oldest candle. + */ + onRequestMoreHistory?: (params: { oldestTimestamp: number }) => void; + + /** Active indicators to display (Token Details). Synced declaratively via useEffect. */ + indicators?: IndicatorType[]; + /** Position lines to overlay (Perps). Set to undefined to clear. */ + positionLines?: PositionLines; + + /** Initial chart type */ + chartType?: ChartType; + /** Show volume bars below the chart */ + showVolume?: boolean; + /** Enable left-side drawing toolbar */ + enableDrawingTools?: boolean; + /** + * TradingView widget features to disable. Defaults to DEFAULT_DISABLED_FEATURES + * (a curated mobile-friendly set). Pass a custom array to re-enable native + * TradingView capabilities like header_widget, timeframes_toolbar, etc. + */ + disabledFeatures?: string[]; + + /** Callback when chart is ready */ + onChartReady?: () => void; + /** Callback when an error occurs */ + onError?: (error: string) => void; + /** Crosshair OHLC data callback (for overlay legend) */ + onCrosshairMove?: (data: CrosshairData | null) => void; + + /** External loading state */ + isLoading?: boolean; +} + +/** + * Imperative ref handle for AdvancedChart. + * Use props for declarative control; ref for one-off imperative actions. + */ +export interface AdvancedChartRef { + addIndicator: ( + indicator: IndicatorType, + inputs?: Record, + ) => void; + removeIndicator: (indicator: IndicatorType) => void; + setChartType: (chartType: ChartType) => void; + reset: () => void; +} + +/** + * Props for the IndicatorToggle component + */ +export interface IndicatorToggleProps { + activeIndicators: IndicatorType[]; + onToggle: (indicator: IndicatorType) => void; + disabled?: boolean; +} diff --git a/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts new file mode 100644 index 00000000000..677b408797e --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/AdvancedChartTemplate.ts @@ -0,0 +1,136 @@ +import type { Theme } from '../../../../util/theme/models'; +import { chartLogicScript } from './webview'; + +/** + * CDN base URL for the TradingView charting library assets. + * + * Production: set MM_CHARTING_LIBRARY_URL to the CloudFront distribution URL + * (trailing slash required). Defaults to the S3 origin until the CloudFront + * distribution is delivered by DevOps. + * + * Local development: override MM_CHARTING_LIBRARY_URL with a local http-server + * URL (e.g. http://localhost:8000/) and run: + * npx http-server --cors -p 8000 + */ +export const CHARTING_LIBRARY_BASE_URL = + process.env.MM_CHARTING_LIBRARY_URL ?? ''; + +const CHARTING_LIBRARY_URL = `${CHARTING_LIBRARY_BASE_URL}charting_library/`; + +/** + * Scheme + host only (no path) for use in CSP frame-src. + * TradingView's iframe_loading_same_origin feature loads sameorigin.html from + * this origin, so frame-src must allow it explicitly. + * e.g. "https://va-mmcx-terminal.s3.us-east-2.amazonaws.com" + */ +const CHARTING_LIBRARY_ORIGIN = (() => { + try { + const { origin } = new URL(CHARTING_LIBRARY_BASE_URL); + return origin; + } catch { + return CHARTING_LIBRARY_BASE_URL; + } +})(); + +/** + * Strip the alpha channel from a hex color string. + * Design tokens may use 9-char hex (#RRGGBBAA); TradingView expects #RRGGBB. + */ +const stripHexAlpha = (hex: string): string => + hex.length === 9 && hex.startsWith('#') ? hex.slice(0, 7) : hex; + +interface ChartFeatures { + enableDrawingTools?: boolean; + showVolume?: boolean; + disabledFeatures?: string[]; +} + +const createConfigScript = ( + libraryUrl: string, + theme: Theme, + features: ChartFeatures, +): string => ` +window.CONFIG = { + libraryUrl: '${libraryUrl}', + theme: { + backgroundColor: '${theme.colors.background.default}', + borderColor: '${stripHexAlpha(theme.colors.border.muted)}', + textColor: '${theme.colors.text.alternative}', + successColor: '${theme.colors.success.default}', + errorColor: '${theme.colors.error.default}', + primaryColor: '${theme.colors.primary.default}' + }, + features: { + enableDrawingTools: ${features.enableDrawingTools ? 'true' : 'false'}, + showVolume: ${features.showVolume ? 'true' : 'false'}, + disabledFeatures: ${JSON.stringify(features.disabledFeatures ?? [])} + } +}; +`; + +/** + * Creates the HTML template for TradingView Advanced Charts. + * + * @param theme - MetaMask theme for styling + * @param features - Optional feature flags forwarded to the WebView + */ +export const createAdvancedChartTemplate = ( + theme: Theme, + features: ChartFeatures = {}, +): string => ` + + + + + TradingView Advanced Chart + + + + + +
Loading chart...
+
+ + + + + +`; + +export default createAdvancedChartTemplate; diff --git a/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx new file mode 100644 index 00000000000..ed58875970d --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/TimeRangeSelector.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Pressable, StyleSheet } from 'react-native'; +import { + Box, + Text, + TextVariant, + BoxFlexDirection, + BoxAlignItems, + BoxJustifyContent, +} from '@metamask/design-system-react-native'; +import { useStyles } from '../../../../component-library/hooks'; +import { Theme } from '../../../../util/theme/models'; + +export type TimeRange = '1H' | '1D' | '1W' | '1M' | 'YTD' | 'ALL'; + +/** Valid Hyperliquid candle interval values */ +export type CandleInterval = '1m' | '15m' | '1h' | '4h' | '1d'; + +export interface TimeRangeConfig { + /** Hyperliquid candle interval */ + hlInterval: CandleInterval; + /** Number of candles to fetch */ + count: number; +} + +const ytdDays = () => { + const now = new Date(); + const startOfYear = Date.UTC(now.getFullYear(), 0, 1); + const today = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); + return Math.round((today - startOfYear) / 86_400_000) + 1; +}; + +export const TIME_RANGE_CONFIGS: Record = { + '1H': { hlInterval: '1m', count: 60 }, + '1D': { hlInterval: '15m', count: 96 }, + '1W': { hlInterval: '1h', count: 168 }, + '1M': { hlInterval: '4h', count: 180 }, + YTD: { hlInterval: '1d', count: Math.min(ytdDays(), 500) }, + ALL: { hlInterval: '1d', count: 500 }, +}; + +const TIME_RANGES: TimeRange[] = ['1H', '1D', '1W', '1M', 'YTD', 'ALL']; + +interface TimeRangeSelectorProps { + selected: TimeRange; + onSelect: (range: TimeRange) => void; + /** Optional subset of ranges to display. Defaults to all. */ + ranges?: TimeRange[]; +} + +const selectorStyleSheet = (params: { theme: Theme }) => { + const { theme } = params; + return StyleSheet.create({ + button: { + paddingHorizontal: 14, + paddingVertical: 8, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + buttonSelected: { + backgroundColor: theme.colors.background.muted, + }, + buttonPressed: { + opacity: 0.7, + }, + }); +}; + +const TimeRangeSelector: React.FC = ({ + selected, + onSelect, + ranges = TIME_RANGES, +}) => { + const { styles } = useStyles(selectorStyleSheet, {}); + + return ( + + {ranges.map((range) => { + const isSelected = selected === range; + return ( + [ + styles.button, + isSelected && styles.buttonSelected, + pressed && styles.buttonPressed, + ]} + onPress={() => onSelect(range)} + > + + {range} + + + ); + })} + + ); +}; + +export default TimeRangeSelector; diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx new file mode 100644 index 00000000000..382bf5b65a6 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/__tests__/AdvancedChart.test.tsx @@ -0,0 +1,437 @@ +import React from 'react'; +import { render, act } from '@testing-library/react-native'; +import AdvancedChart from '../AdvancedChart'; +import { + ChartType, + type OHLCVBar, + type AdvancedChartRef, + type PositionLines, +} from '../AdvancedChart.types'; + +const mockPostMessage = jest.fn(); + +jest.mock('@metamask/react-native-webview', () => { + const { View } = jest.requireActual('react-native'); + const { forwardRef, useImperativeHandle } = jest.requireActual('react'); + const MockWebView = forwardRef( + (props: Record, ref: React.Ref) => { + useImperativeHandle(ref, () => ({ + postMessage: mockPostMessage, + reload: jest.fn(), + })); + return ; + }, + ); + MockWebView.displayName = 'MockWebView'; + return { WebView: MockWebView }; +}); + +const MOCK_BARS: OHLCVBar[] = [ + { time: 1000000, open: 10, high: 12, low: 9, close: 11, volume: 100 }, + { time: 1000300, open: 11, high: 13, low: 10, close: 12, volume: 200 }, +]; + +describe('AdvancedChart', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders without crashing', () => { + const { getByText } = render(); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); + + it('shows loading overlay when isLoading is true', () => { + const { getByText } = render( + , + ); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); + + it('sends OHLCV data on WebView load end', () => { + const { getByTestId } = render(); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onLoadEnd(); + }); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_OHLCV_DATA', + payload: { data: MOCK_BARS }, + }), + ); + }); + + it('exposes addIndicator via ref', () => { + const ref = React.createRef(); + render(); + + expect(ref.current).toBeTruthy(); + expect(ref.current?.addIndicator).toBeInstanceOf(Function); + expect(ref.current?.removeIndicator).toBeInstanceOf(Function); + expect(ref.current?.setChartType).toBeInstanceOf(Function); + expect(ref.current?.reset).toBeInstanceOf(Function); + }); + + it('calls onChartReady when chart reports ready', () => { + const onChartReady = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + const onMessage = webView.props.onMessage; + + act(() => { + onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(1); + }); + + it('calls onError when chart reports an error', () => { + const onError = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'test error' }, + }), + }, + }); + }); + + expect(onError).toHaveBeenCalledWith('test error'); + }); + + it('does not destroy the chart for errors after CHART_READY', () => { + const onError = jest.fn(); + const { getByTestId, queryByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Failed to add indicator: timeout' }, + }), + }, + }); + }); + + expect(onError).toHaveBeenCalledWith('Failed to add indicator: timeout'); + expect(queryByText(/Failed to load chart/)).not.toBeOnTheScreen(); + expect(getByTestId('mock-webview')).toBeOnTheScreen(); + }); + + it('calls onCrosshairMove when crosshair data arrives', () => { + const onCrosshairMove = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + const crosshairData = { + time: 1000000, + open: 10, + high: 12, + low: 9, + close: 11, + volume: 100, + }; + + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'CROSSHAIR_MOVE', + payload: { data: crosshairData }, + }), + }, + }); + }); + + expect(onCrosshairMove).toHaveBeenCalledWith(crosshairData); + }); + + it('calls onRequestMoreHistory when WebView requests more data', () => { + const onRequestMoreHistory = jest.fn(); + const { getByTestId } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'NEED_MORE_HISTORY', + payload: { oldestTimestamp: 1000000 }, + }), + }, + }); + }); + + expect(onRequestMoreHistory).toHaveBeenCalledTimes(1); + }); + + it('sends SET_POSITION_LINES when positionLines prop changes', () => { + const position: PositionLines = { + side: 'long', + entryPrice: 1991.7, + liquidationPrice: 1357.83, + }; + + const { getByTestId, rerender } = render( + , + ); + + // Simulate chart ready + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_POSITION_LINES', + payload: { position }, + }), + ); + }); + + it('sends SET_POSITION_LINES with null when positionLines cleared', () => { + const position: PositionLines = { + side: 'long', + entryPrice: 1991.7, + }; + + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_POSITION_LINES', + payload: { position: null }, + }), + ); + }); + + it('sends REALTIME_UPDATE when realtimeBar changes', () => { + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + const newBar: OHLCVBar = { + time: 1000600, + open: 12, + high: 14, + low: 11, + close: 13, + volume: 300, + }; + + rerender(); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'REALTIME_UPDATE', + payload: { bar: newBar }, + }), + ); + }); + + it('sends SET_CHART_TYPE when chartType prop changes', () => { + const { getByTestId, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + mockPostMessage.mockClear(); + + rerender( + , + ); + + expect(mockPostMessage).toHaveBeenCalledWith( + JSON.stringify({ + type: 'SET_CHART_TYPE', + payload: { type: ChartType.Line }, + }), + ); + }); + + it('resets chart state when htmlContent changes so sync effects re-fire', () => { + const onChartReady = jest.fn(); + const { getByTestId, getByText, rerender } = render( + , + ); + + const webView = getByTestId('mock-webview'); + + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(1); + mockPostMessage.mockClear(); + + rerender( + , + ); + + expect(getByText('Loading chart...')).toBeOnTheScreen(); + + act(() => { + webView.props.onLoadEnd(); + }); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ type: 'CHART_READY', payload: {} }), + }, + }); + }); + + expect(onChartReady).toHaveBeenCalledTimes(2); + + const addIndicatorCall = mockPostMessage.mock.calls.find((call) => { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'ADD_INDICATOR' && parsed.payload.name === 'RSI'; + }); + expect(addIndicatorCall).toBeDefined(); + }); + + it('displays error screen for errors before CHART_READY', () => { + const { getByTestId, getByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Load failed' }, + }), + }, + }); + }); + + expect(getByText(/Load failed/)).toBeOnTheScreen(); + }); + + it('recovers from error state when reset() is called via ref', () => { + const ref = React.createRef(); + const { getByTestId, getByText, queryByText } = render( + , + ); + + const webView = getByTestId('mock-webview'); + act(() => { + webView.props.onMessage({ + nativeEvent: { + data: JSON.stringify({ + type: 'ERROR', + payload: { message: 'Load failed' }, + }), + }, + }); + }); + + expect(getByText(/Load failed/)).toBeOnTheScreen(); + + act(() => { + ref.current?.reset(); + }); + + expect(queryByText(/Load failed/)).not.toBeOnTheScreen(); + expect(getByText('Loading chart...')).toBeOnTheScreen(); + }); +}); diff --git a/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx new file mode 100644 index 00000000000..21fa02ee4ba --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/__tests__/TimeRangeSelector.test.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { render, fireEvent } from '@testing-library/react-native'; +import TimeRangeSelector, { + TIME_RANGE_CONFIGS, + type TimeRange, +} from '../TimeRangeSelector'; + +describe('TimeRangeSelector', () => { + const defaultProps = { + selected: '1D' as TimeRange, + onSelect: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders all time range buttons by default', () => { + const { getByText } = render(); + + expect(getByText('1H')).toBeOnTheScreen(); + expect(getByText('1D')).toBeOnTheScreen(); + expect(getByText('1W')).toBeOnTheScreen(); + expect(getByText('1M')).toBeOnTheScreen(); + expect(getByText('YTD')).toBeOnTheScreen(); + expect(getByText('ALL')).toBeOnTheScreen(); + }); + + it('renders only specified ranges when ranges prop is provided', () => { + const { getByText, queryByText } = render( + , + ); + + expect(getByText('1H')).toBeOnTheScreen(); + expect(getByText('1D')).toBeOnTheScreen(); + expect(getByText('1W')).toBeOnTheScreen(); + expect(queryByText('1M')).not.toBeOnTheScreen(); + expect(queryByText('YTD')).not.toBeOnTheScreen(); + expect(queryByText('ALL')).not.toBeOnTheScreen(); + }); + + it('calls onSelect with the tapped range', () => { + const onSelect = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText('1W')); + + expect(onSelect).toHaveBeenCalledWith('1W'); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it('calls onSelect when tapping the already selected range', () => { + const onSelect = jest.fn(); + const { getByText } = render( + , + ); + + fireEvent.press(getByText('1D')); + + expect(onSelect).toHaveBeenCalledWith('1D'); + }); + + describe('TIME_RANGE_CONFIGS', () => { + it('has a config for every time range', () => { + const ranges: TimeRange[] = ['1H', '1D', '1W', '1M', 'YTD', 'ALL']; + + ranges.forEach((range) => { + expect(TIME_RANGE_CONFIGS[range]).toBeDefined(); + expect(TIME_RANGE_CONFIGS[range].hlInterval).toBeTruthy(); + expect(TIME_RANGE_CONFIGS[range].count).toBeGreaterThan(0); + }); + }); + + it('maps 1H to 1-minute candles', () => { + expect(TIME_RANGE_CONFIGS['1H'].hlInterval).toBe('1m'); + expect(TIME_RANGE_CONFIGS['1H'].count).toBe(60); + }); + + it('maps 1D to 15-minute candles', () => { + expect(TIME_RANGE_CONFIGS['1D'].hlInterval).toBe('15m'); + expect(TIME_RANGE_CONFIGS['1D'].count).toBe(96); + }); + + it('maps 1W to 1-hour candles', () => { + expect(TIME_RANGE_CONFIGS['1W'].hlInterval).toBe('1h'); + expect(TIME_RANGE_CONFIGS['1W'].count).toBe(168); + }); + + it('maps 1M to 4-hour candles', () => { + expect(TIME_RANGE_CONFIGS['1M'].hlInterval).toBe('4h'); + expect(TIME_RANGE_CONFIGS['1M'].count).toBe(180); + }); + + it('maps ALL to daily candles with 500 count', () => { + expect(TIME_RANGE_CONFIGS.ALL.hlInterval).toBe('1d'); + expect(TIME_RANGE_CONFIGS.ALL.count).toBe(500); + }); + + it('maps YTD to daily candles capped at 500', () => { + expect(TIME_RANGE_CONFIGS.YTD.hlInterval).toBe('1d'); + expect(TIME_RANGE_CONFIGS.YTD.count).toBeGreaterThan(0); + expect(TIME_RANGE_CONFIGS.YTD.count).toBeLessThanOrEqual(500); + }); + }); +}); diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js new file mode 100644 index 00000000000..27fd979b4e7 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogic.js @@ -0,0 +1,875 @@ +/** + * TradingView Chart WebView Logic + * + * Generic charting logic for TradingView Advanced Charts. + * Embedded into the WebView HTML at runtime via chartLogicString.ts. + * + * CONFIG is injected before this script runs and contains: + * - libraryUrl: string + * - theme: { backgroundColor, borderColor, textColor, successColor, errorColor, primaryColor } + */ + +// ============================================ +// Global State +// ============================================ +window.chartWidget = null; +window.ohlcvData = []; +window.currentSymbol = 'ASSET'; +window.activeStudies = {}; +window.positionShapeIds = []; +window.isChartReady = false; +window.pendingMessages = []; +window.libraryLoaded = false; +window.libraryError = null; +window.realtimeCallbacks = {}; +window.pendingGetBarsCallback = null; + +// ============================================ +// Communication with React Native +// ============================================ +function sendToReactNative(type, payload) { + payload = payload || {}; + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ type: type, payload: payload }), + ); + } +} + +// ============================================ +// Message Handler +// ============================================ +function handleMessage(event) { + try { + var message = + typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + if (!window.isChartReady && message.type !== 'SET_OHLCV_DATA') { + window.pendingMessages.push(message); + return; + } + + switch (message.type) { + case 'SET_OHLCV_DATA': + handleSetOHLCVData(message.payload); + break; + case 'ADD_INDICATOR': + handleAddIndicator(message.payload); + break; + case 'REMOVE_INDICATOR': + handleRemoveIndicator(message.payload); + break; + case 'SET_CHART_TYPE': + handleSetChartType(message.payload); + break; + case 'SET_POSITION_LINES': + handleSetPositionLines(message.payload); + break; + case 'REALTIME_UPDATE': + handleRealtimeUpdate(message.payload); + break; + case 'TOGGLE_VOLUME': + handleToggleVolume(message.payload); + break; + } + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +window.addEventListener('message', handleMessage); +document.addEventListener('message', handleMessage); + +// ============================================ +// Data Handlers +// ============================================ +var INTERVAL_MS_TO_TV = { + 60000: '1', + 180000: '3', + 300000: '5', + 900000: '15', + 1800000: '30', + 3600000: '60', + 7200000: '120', + 14400000: '240', + 28800000: '480', + 43200000: '720', + 86400000: '1D', + 259200000: '3D', + 604800000: '1W', + 2592000000: '1M', +}; + +function detectResolution(data) { + if (data.length < 2) return '5'; + // Use median of first few diffs to avoid gaps skewing the result + var diffs = []; + var len = Math.min(data.length - 1, 10); + for (var i = 0; i < len; i++) { + diffs.push(data[i + 1].time - data[i].time); + } + diffs.sort(function (a, b) { + return a - b; + }); + var median = diffs[Math.floor(diffs.length / 2)]; + + // Find closest match + var keys = Object.keys(INTERVAL_MS_TO_TV); + var best = '5'; + var bestDist = Infinity; + for (var k = 0; k < keys.length; k++) { + var d = Math.abs(Number(keys[k]) - median); + if (d < bestDist) { + bestDist = d; + best = INTERVAL_MS_TO_TV[keys[k]]; + } + } + return best; +} + +function handleSetOHLCVData(payload) { + if (!payload || !payload.data || payload.data.length === 0) return; + + window.ohlcvData = payload.data; + + var newResolution = detectResolution(window.ohlcvData); + var hasPending = !!window.pendingGetBarsCallback; + + // TODO: Early return bypasses resolution-change handling at lines 146–170. + // If SET_OHLCV_DATA arrives at a different resolution while a history + // request is pending (if a user switches interval during a pending chart candle history navigation request), + // the widget stays at the old resolution while window.currentResolution + // reflects the new one. Fix when wiring up history pagination for Perps. + if (hasPending) { + var pending = window.pendingGetBarsCallback; + window.pendingGetBarsCallback = null; + window.currentResolution = newResolution; + resolvePendingGetBars(pending); + return; + } + + if (window.chartWidget && window.isChartReady) { + if (window.currentResolution !== newResolution) { + window.currentResolution = newResolution; + try { + window.chartWidget + .activeChart() + .setResolution(newResolution, function () {}); + } catch (e) { + window.chartWidget.remove(); + window.chartWidget = null; + window.isChartReady = false; + window.activeStudies = {}; + window.volumeStudyId = null; + window.positionShapeIds = []; + window.realtimeCallbacks = {}; + window.pendingGetBarsCallback = null; + initChart(); + } + } else { + try { + window.chartWidget.activeChart().resetData(); + } catch (e) { + // resetData can fail if chart is in a transitional state + } + } + } else if (window.chartWidget && !window.isChartReady) { + window.currentResolution = newResolution; + } else if (!window.chartWidget) { + window.currentResolution = newResolution; + libraryLoadAttempts = 0; + initChart(); + } +} + +// ============================================ +// Realtime Update Handler +// ============================================ +function handleRealtimeUpdate(payload) { + if (!payload || !payload.bar) return; + + var bar = payload.bar; + + // Append or update the last bar in the local data store + if (window.ohlcvData.length > 0) { + var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; + if (lastBar.time === bar.time) { + window.ohlcvData[window.ohlcvData.length - 1] = bar; + } else { + window.ohlcvData.push(bar); + } + } else { + window.ohlcvData.push(bar); + } + + // Forward to all active TradingView subscribeBars callbacks + var tick = { + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }; + var guids = Object.keys(window.realtimeCallbacks); + for (var i = 0; i < guids.length; i++) { + window.realtimeCallbacks[guids[i]](tick); + } +} + +// ============================================ +// Indicator Handlers +// +// Curated subset for Token Details mobile UX. Consumers needing the full +// TradingView study picker can re-enable header_widget via disabledFeatures +// prop, which exposes TradingView's native indicator UI. +// ============================================ +function handleAddIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + + if (window.activeStudies[indicatorName]) { + return; + } + + try { + var chart = window.chartWidget.activeChart(); + var studyName, inputs; + + switch (indicatorName) { + case 'MACD': + studyName = 'MACD'; + inputs = { in_0: 12, in_1: 26, in_2: 9 }; + break; + case 'RSI': + studyName = 'Relative Strength Index'; + inputs = { in_0: 14 }; + break; + case 'MA200': + studyName = 'Moving Average'; + inputs = { in_0: 200 }; + break; + default: + studyName = indicatorName; + inputs = payload.inputs || {}; + break; + } + + chart + .createStudy(studyName, false, false, inputs) + .then(function (studyId) { + window.activeStudies[indicatorName] = studyId; + sendToReactNative('INDICATOR_ADDED', { + name: indicatorName, + id: String(studyId), + }); + }) + .catch(function (error) { + sendToReactNative('ERROR', { + message: 'Failed to add indicator: ' + error.message, + }); + }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +function handleRemoveIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + var studyId = window.activeStudies[indicatorName]; + + if (!studyId) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.removeEntity(studyId); + delete window.activeStudies[indicatorName]; + sendToReactNative('INDICATOR_REMOVED', { name: indicatorName }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Chart Type Handler +// ============================================ +function handleSetChartType(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.setChartType(payload.type); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Position Lines (unified SET_POSITION_LINES) +// ============================================ + +function clearPositionLines() { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + for (var i = 0; i < window.positionShapeIds.length; i++) { + try { + chart.removeEntity(window.positionShapeIds[i]); + } catch (e) { + // Shape may already be removed + } + } + window.positionShapeIds = []; + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to clear position lines: ' + error.message, + }); + } +} + +function handleSetPositionLines(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + // Clear existing lines first + clearPositionLines(); + + // null or missing position means "clear only" + if (!payload || !payload.position) return; + + var position = payload.position; + var theme = window.CONFIG.theme; + + try { + var chart = window.chartWidget.activeChart(); + var lines = []; + + if (position.entryPrice) { + lines.push({ + price: position.entryPrice, + text: 'Entry', + color: '#858585', + lineStyle: 2, + }); + } + if (position.takeProfitPrice) { + lines.push({ + price: position.takeProfitPrice, + text: 'TP', + color: theme.successColor, + lineStyle: 2, + }); + } + if (position.stopLossPrice) { + lines.push({ + price: position.stopLossPrice, + text: 'SL', + color: '#858585', + lineStyle: 2, + }); + } + if (position.liquidationPrice) { + lines.push({ + price: position.liquidationPrice, + text: 'Liq', + color: theme.errorColor, + lineStyle: 2, + }); + } + // TODO: currentPrice is defined in PositionLines but not yet rendered here. + // Add a line for position.currentPrice (e.g. a solid line showing live mark + // price) when the Perps integration is ready. + + for (var i = 0; i < lines.length; i++) { + (function (line) { + chart + .createShape( + { price: line.price }, + { + shape: 'horizontal_line', + lock: true, + disableSelection: true, + disableSave: true, + disableUndo: true, + text: line.text, + overrides: { + linecolor: line.color, + linestyle: line.lineStyle, + linewidth: 1, + showLabel: true, + textcolor: line.color, + fontsize: 11, + horzLabelsAlign: 'right', + showPrice: true, + }, + }, + ) + .then(function (entityId) { + if (entityId) { + window.positionShapeIds.push(entityId); + } + }) + .catch(function () { + // Shape creation can fail silently + }); + })(lines[i]); + } + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to add position lines: ' + error.message, + }); + } +} + +// ============================================ +// Volume Helpers +// ============================================ +window.volumeStudyId = null; + +function createVolumeStudy() { + if (!window.chartWidget || !window.isChartReady) return; + if (window.volumeStudyId) return; + + try { + window.chartWidget + .activeChart() + .createStudy('Volume', false, false, {}, { 'volume ma.visible': false }) + .then(function (studyId) { + window.volumeStudyId = studyId; + }) + .catch(function () { + // Volume study creation failed + }); + } catch (e) { + // Not critical + } +} + +function handleToggleVolume(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload) return; + + if (payload.visible && !window.volumeStudyId) { + createVolumeStudy(); + } else if (!payload.visible && window.volumeStudyId) { + try { + window.chartWidget.activeChart().removeEntity(window.volumeStudyId); + } catch (e) { + // Already removed + } + window.volumeStudyId = null; + } +} + +// ============================================ +// Custom Datafeed Implementation +// ============================================ + +/** + * TradingView variable_tick_size string. + * + * Tells TradingView to dynamically adjust pricescale/minmov based on + * the current price level. Format: "tickSize threshold tickSize threshold …" + * where each tickSize applies for prices below the next threshold, and + * the last tickSize applies to all prices above the last threshold. + * + * This replaces a manual pricescale computation and adapts automatically + * as prices change (e.g. meme token pumps from $0.0001 to $1). + */ +var VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '1', // prices < $1 → 4 dp + '0.01', + '10000', // prices < $10000 → 2 dp + '0.1', // prices ≥ $10000 → 1 dp +].join(' '); + +function filterBarsForRange(fromMs, toMs, countBack) { + var barsInRange = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time >= fromMs && b.time < toMs) { + barsInRange.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + if (barsInRange.length < countBack) { + var allBeforeTo = []; + for (var j = 0; j < window.ohlcvData.length; j++) { + if (window.ohlcvData[j].time < toMs) { + allBeforeTo.push(window.ohlcvData[j]); + } + } + var startIdx = Math.max(0, allBeforeTo.length - countBack); + barsInRange = []; + for (var k = startIdx; k < allBeforeTo.length; k++) { + var bar = allBeforeTo[k]; + barsInRange.push({ + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }); + } + } + + return barsInRange; +} + +function resolvePendingGetBars(pending) { + var currentOldest = + window.ohlcvData.length > 0 ? window.ohlcvData[0].time : 0; + + if (currentOldest >= pending.oldestAtDefer) { + pending.onResult([], { noData: true }); + return; + } + + // Return only the newly fetched bars (older than what we had before deferring). + // TradingView already has bars from oldestAtDefer onward. + var bars = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time < pending.oldestAtDefer) { + bars.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + pending.onResult(bars, { noData: false }); +} + +var customDatafeed = { + onReady: function (callback) { + setTimeout(function () { + callback({ + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + supports_marks: false, + supports_timescale_marks: false, + supports_time: true, + }); + }, 0); + }, + + searchSymbols: function (userInput, exchange, symbolType, onResult) { + onResult([]); + }, + + resolveSymbol: function (symbolName, onResolve) { + setTimeout(function () { + onResolve({ + name: symbolName, + ticker: symbolName, + description: symbolName, + type: 'crypto', + session: '24x7', + timezone: 'Etc/UTC', + exchange: '', + minmov: 1, + pricescale: 100, + variable_tick_size: VARIABLE_TICK_SIZE, + has_intraday: true, + has_daily: true, + has_weekly_and_monthly: true, + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + volume_precision: 0, + data_status: 'streaming', + }); + }, 0); + }, + + getBars: function (symbolInfo, resolution, periodParams, onResult, onError) { + try { + var fromMs = periodParams.from * 1000; + var toMs = periodParams.to * 1000; + var countBack = periodParams.countBack; + var firstRequest = periodParams.firstDataRequest; + + var bars = filterBarsForRange(fromMs, toMs, countBack); + + if (bars.length > 0) { + onResult(bars, { noData: false }); + return; + } + + if (firstRequest || window.ohlcvData.length === 0) { + onResult([], { noData: true }); + return; + } + + var oldestTs = window.ohlcvData[0].time; + + window.pendingGetBarsCallback = { + onResult: onResult, + oldestAtDefer: oldestTs, + }; + + sendToReactNative('NEED_MORE_HISTORY', { oldestTimestamp: oldestTs }); + } catch (error) { + onError(error.message); + } + }, + + subscribeBars: function (symbolInfo, resolution, onTick, listenerGuid) { + window.realtimeCallbacks[listenerGuid] = onTick; + }, + + unsubscribeBars: function (listenerGuid) { + delete window.realtimeCallbacks[listenerGuid]; + }, +}; + +// ============================================ +// Library Loading +// ============================================ +var libraryLoadAttempts = 0; +var maxLibraryLoadAttempts = 50; + +function loadLibrary() { + var scriptUrl = window.CONFIG.libraryUrl + 'charting_library.js'; + + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = scriptUrl; + script.onload = function () { + window.libraryLoaded = true; + if (window.ohlcvData.length > 0) { + initChart(); + } + }; + script.onerror = function () { + window.libraryError = + 'Failed to load TradingView library. URL: ' + scriptUrl; + document.getElementById('loading-overlay').innerHTML = + '
' + + '

Failed to load chart library

' + + '

URL: ' + + scriptUrl + + '

' + + '

Check S3 access or CORS configuration.

' + + '
'; + sendToReactNative('ERROR', { message: window.libraryError }); + }; + document.head.appendChild(script); +} + +// ============================================ +// Chart Initialization +// ============================================ +function initChart() { + if (window.chartWidget) return; + + if (typeof TradingView === 'undefined') { + libraryLoadAttempts++; + if (libraryLoadAttempts >= maxLibraryLoadAttempts) { + var errorMsg = + 'TradingView library failed to initialize after ' + + maxLibraryLoadAttempts * 100 + + 'ms'; + document.getElementById('loading-overlay').textContent = errorMsg; + sendToReactNative('ERROR', { message: errorMsg }); + return; + } + setTimeout(initChart, 100); + return; + } + + if (window.ohlcvData.length === 0) { + return; + } + + try { + var theme = window.CONFIG.theme; + var features = window.CONFIG.features || {}; + + // Disabled features are passed from React Native via CONFIG.features.disabledFeatures. + // Defaults are set in DEFAULT_DISABLED_FEATURES (AdvancedChart.types.ts) and are + // optimized for the Token Details mobile UX. Consumers needing TradingView's + // native UI (e.g. Perps) can override via the disabledFeatures prop. + var disabledFeatures = (features.disabledFeatures || []).slice(); + + if (!features.enableDrawingTools) { + disabledFeatures.push('left_toolbar'); + disabledFeatures.push('context_menus'); + } + + window.chartWidget = new TradingView.widget({ + symbol: window.currentSymbol, + interval: window.currentResolution || '5', + container: 'tv_chart_container', + datafeed: customDatafeed, + library_path: window.CONFIG.libraryUrl, + locale: 'en', + fullscreen: false, + autosize: true, + theme: 'Dark', + + disabled_features: disabledFeatures, + enabled_features: ['study_templates', 'iframe_loading_same_origin'], + + overrides: { + 'paneProperties.background': theme.backgroundColor, + 'paneProperties.backgroundType': 'solid', + 'paneProperties.vertGridProperties.color': theme.borderColor, + 'paneProperties.horzGridProperties.color': theme.borderColor, + 'scalesProperties.textColor': theme.textColor, + 'scalesProperties.lineColor': theme.borderColor, + 'scalesProperties.fontSize': 11, + 'scalesProperties.showStudyLastValue': true, + 'scalesProperties.showSeriesLastValue': true, + 'scalesProperties.showSymbolLabels': true, + 'scalesProperties.showRightScale': true, + 'scalesProperties.showLeftScale': false, + 'paneProperties.bottomMargin': 5, + 'mainSeriesProperties.candleStyle.upColor': theme.successColor, + 'mainSeriesProperties.candleStyle.downColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.borderUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.borderDownColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, + }, + + loading_screen: { + backgroundColor: theme.backgroundColor, + foregroundColor: theme.primaryColor, + }, + }); + + window.chartWidget.onChartReady(function () { + window.isChartReady = true; + document.getElementById('loading-overlay').classList.add('hidden'); + + try { + var timeScale = window.chartWidget.activeChart().getTimeScale(); + timeScale.defaultRightOffset().setValue(0); + timeScale.setRightOffset(0); + } catch (e) {} + + sendToReactNative('CHART_READY', {}); + + // Set up crosshair move listener for OHLC overlay + try { + window.chartWidget + .activeChart() + .crossHairMoved() + .subscribe(null, function (params) { + if ( + params && + params.price !== undefined && + params.time !== undefined + ) { + // Find the bar closest to the crosshair time + var targetTime = params.time * 1000; + var closestBar = null; + var minDiff = Infinity; + for (var i = 0; i < window.ohlcvData.length; i++) { + var diff = Math.abs(window.ohlcvData[i].time - targetTime); + if (diff < minDiff) { + minDiff = diff; + closestBar = window.ohlcvData[i]; + } + } + if (closestBar) { + sendToReactNative('CROSSHAIR_MOVE', { + data: { + time: closestBar.time, + open: closestBar.open, + high: closestBar.high, + low: closestBar.low, + close: closestBar.close, + volume: closestBar.volume, + }, + }); + } + } else { + sendToReactNative('CROSSHAIR_MOVE', { data: null }); + } + }); + } catch (e) { + // Crosshair subscription not critical + } + + // Auto-add volume study if showVolume is true (no SMA overlay) + if (features.showVolume) { + createVolumeStudy(); + } + + // Process pending messages + window.pendingMessages.forEach(function (msg) { + handleMessage({ data: msg }); + }); + window.pendingMessages = []; + }); + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to initialize chart: ' + error.message, + }); + } +} + +// ============================================ +// Start +// ============================================ +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + loadLibrary(); + }); +} else { + loadLibrary(); +} diff --git a/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts new file mode 100644 index 00000000000..7017c990069 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/chartLogicString.ts @@ -0,0 +1,880 @@ +/** + * AUTO-GENERATED - DO NOT EDIT DIRECTLY + * + * This file is generated from chartLogic.js by syncChartLogic.js + * Edit chartLogic.js instead, then run: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +// eslint-disable-next-line import/no-default-export +export default `/** + * TradingView Chart WebView Logic + * + * Generic charting logic for TradingView Advanced Charts. + * Embedded into the WebView HTML at runtime via chartLogicString.ts. + * + * CONFIG is injected before this script runs and contains: + * - libraryUrl: string + * - theme: { backgroundColor, borderColor, textColor, successColor, errorColor, primaryColor } + */ + +// ============================================ +// Global State +// ============================================ +window.chartWidget = null; +window.ohlcvData = []; +window.currentSymbol = 'ASSET'; +window.activeStudies = {}; +window.positionShapeIds = []; +window.isChartReady = false; +window.pendingMessages = []; +window.libraryLoaded = false; +window.libraryError = null; +window.realtimeCallbacks = {}; +window.pendingGetBarsCallback = null; + +// ============================================ +// Communication with React Native +// ============================================ +function sendToReactNative(type, payload) { + payload = payload || {}; + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage( + JSON.stringify({ type: type, payload: payload }), + ); + } +} + +// ============================================ +// Message Handler +// ============================================ +function handleMessage(event) { + try { + var message = + typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + + if (!window.isChartReady && message.type !== 'SET_OHLCV_DATA') { + window.pendingMessages.push(message); + return; + } + + switch (message.type) { + case 'SET_OHLCV_DATA': + handleSetOHLCVData(message.payload); + break; + case 'ADD_INDICATOR': + handleAddIndicator(message.payload); + break; + case 'REMOVE_INDICATOR': + handleRemoveIndicator(message.payload); + break; + case 'SET_CHART_TYPE': + handleSetChartType(message.payload); + break; + case 'SET_POSITION_LINES': + handleSetPositionLines(message.payload); + break; + case 'REALTIME_UPDATE': + handleRealtimeUpdate(message.payload); + break; + case 'TOGGLE_VOLUME': + handleToggleVolume(message.payload); + break; + } + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +window.addEventListener('message', handleMessage); +document.addEventListener('message', handleMessage); + +// ============================================ +// Data Handlers +// ============================================ +var INTERVAL_MS_TO_TV = { + 60000: '1', + 180000: '3', + 300000: '5', + 900000: '15', + 1800000: '30', + 3600000: '60', + 7200000: '120', + 14400000: '240', + 28800000: '480', + 43200000: '720', + 86400000: '1D', + 259200000: '3D', + 604800000: '1W', + 2592000000: '1M', +}; + +function detectResolution(data) { + if (data.length < 2) return '5'; + // Use median of first few diffs to avoid gaps skewing the result + var diffs = []; + var len = Math.min(data.length - 1, 10); + for (var i = 0; i < len; i++) { + diffs.push(data[i + 1].time - data[i].time); + } + diffs.sort(function (a, b) { + return a - b; + }); + var median = diffs[Math.floor(diffs.length / 2)]; + + // Find closest match + var keys = Object.keys(INTERVAL_MS_TO_TV); + var best = '5'; + var bestDist = Infinity; + for (var k = 0; k < keys.length; k++) { + var d = Math.abs(Number(keys[k]) - median); + if (d < bestDist) { + bestDist = d; + best = INTERVAL_MS_TO_TV[keys[k]]; + } + } + return best; +} + +function handleSetOHLCVData(payload) { + if (!payload || !payload.data || payload.data.length === 0) return; + + window.ohlcvData = payload.data; + + var newResolution = detectResolution(window.ohlcvData); + var hasPending = !!window.pendingGetBarsCallback; + + if (hasPending) { + var pending = window.pendingGetBarsCallback; + window.pendingGetBarsCallback = null; + window.currentResolution = newResolution; + resolvePendingGetBars(pending); + return; + } + + if (window.chartWidget && window.isChartReady) { + if (window.currentResolution !== newResolution) { + window.currentResolution = newResolution; + try { + window.chartWidget + .activeChart() + .setResolution(newResolution, function () {}); + } catch (e) { + window.chartWidget.remove(); + window.chartWidget = null; + window.isChartReady = false; + window.activeStudies = {}; + window.volumeStudyId = null; + window.positionShapeIds = []; + window.realtimeCallbacks = {}; + window.pendingGetBarsCallback = null; + initChart(); + } + } else { + try { + window.chartWidget.activeChart().resetData(); + } catch (e) { + // resetData can fail if chart is in a transitional state + } + } + } else if (window.chartWidget && !window.isChartReady) { + window.currentResolution = newResolution; + } else if (!window.chartWidget) { + window.currentResolution = newResolution; + libraryLoadAttempts = 0; + initChart(); + } +} + +// ============================================ +// Realtime Update Handler +// ============================================ +function handleRealtimeUpdate(payload) { + if (!payload || !payload.bar) return; + + var bar = payload.bar; + + // Append or update the last bar in the local data store + if (window.ohlcvData.length > 0) { + var lastBar = window.ohlcvData[window.ohlcvData.length - 1]; + if (lastBar.time === bar.time) { + window.ohlcvData[window.ohlcvData.length - 1] = bar; + } else { + window.ohlcvData.push(bar); + } + } else { + window.ohlcvData.push(bar); + } + + // Forward to all active TradingView subscribeBars callbacks + var tick = { + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }; + var guids = Object.keys(window.realtimeCallbacks); + for (var i = 0; i < guids.length; i++) { + window.realtimeCallbacks[guids[i]](tick); + } +} + +// ============================================ +// Indicator Handlers +// +// Curated subset for Token Details mobile UX. Consumers needing the full +// TradingView study picker can re-enable header_widget via disabledFeatures +// prop, which exposes TradingView's native indicator UI. +// ============================================ +function handleAddIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + + if (window.activeStudies[indicatorName]) { + return; + } + + try { + var chart = window.chartWidget.activeChart(); + var studyName, inputs; + + switch (indicatorName) { + case 'MACD': + studyName = 'MACD'; + inputs = { in_0: 12, in_1: 26, in_2: 9 }; + break; + case 'RSI': + studyName = 'Relative Strength Index'; + inputs = { in_0: 14 }; + break; + case 'MA200': + studyName = 'Moving Average'; + inputs = { in_0: 200 }; + break; + default: + studyName = indicatorName; + inputs = payload.inputs || {}; + break; + } + + chart + .createStudy(studyName, false, false, inputs) + .then(function (studyId) { + window.activeStudies[indicatorName] = studyId; + sendToReactNative('INDICATOR_ADDED', { + name: indicatorName, + id: String(studyId), + }); + }) + .catch(function (error) { + sendToReactNative('ERROR', { + message: 'Failed to add indicator: ' + error.message, + }); + }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +function handleRemoveIndicator(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload || !payload.name) return; + + var indicatorName = payload.name; + var studyId = window.activeStudies[indicatorName]; + + if (!studyId) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.removeEntity(studyId); + delete window.activeStudies[indicatorName]; + sendToReactNative('INDICATOR_REMOVED', { name: indicatorName }); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Chart Type Handler +// ============================================ +function handleSetChartType(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + chart.setChartType(payload.type); + } catch (error) { + sendToReactNative('ERROR', { message: error.message }); + } +} + +// ============================================ +// Position Lines (unified SET_POSITION_LINES) +// ============================================ + +function clearPositionLines() { + if (!window.chartWidget || !window.isChartReady) return; + + try { + var chart = window.chartWidget.activeChart(); + for (var i = 0; i < window.positionShapeIds.length; i++) { + try { + chart.removeEntity(window.positionShapeIds[i]); + } catch (e) { + // Shape may already be removed + } + } + window.positionShapeIds = []; + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to clear position lines: ' + error.message, + }); + } +} + +function handleSetPositionLines(payload) { + if (!window.chartWidget || !window.isChartReady) return; + + // Clear existing lines first + clearPositionLines(); + + // null or missing position means "clear only" + if (!payload || !payload.position) return; + + var position = payload.position; + var theme = window.CONFIG.theme; + + try { + var chart = window.chartWidget.activeChart(); + var lines = []; + + if (position.entryPrice) { + lines.push({ + price: position.entryPrice, + text: 'Entry', + color: '#858585', + lineStyle: 2, + }); + } + if (position.takeProfitPrice) { + lines.push({ + price: position.takeProfitPrice, + text: 'TP', + color: theme.successColor, + lineStyle: 2, + }); + } + if (position.stopLossPrice) { + lines.push({ + price: position.stopLossPrice, + text: 'SL', + color: '#858585', + lineStyle: 2, + }); + } + if (position.liquidationPrice) { + lines.push({ + price: position.liquidationPrice, + text: 'Liq', + color: theme.errorColor, + lineStyle: 2, + }); + } + // TODO: currentPrice is defined in PositionLines but not yet rendered here. + // Add a line for position.currentPrice (e.g. a solid line showing live mark + // price) when the Perps integration is ready. + + for (var i = 0; i < lines.length; i++) { + (function (line) { + chart + .createShape( + { price: line.price }, + { + shape: 'horizontal_line', + lock: true, + disableSelection: true, + disableSave: true, + disableUndo: true, + text: line.text, + overrides: { + linecolor: line.color, + linestyle: line.lineStyle, + linewidth: 1, + showLabel: true, + textcolor: line.color, + fontsize: 11, + horzLabelsAlign: 'right', + showPrice: true, + }, + }, + ) + .then(function (entityId) { + if (entityId) { + window.positionShapeIds.push(entityId); + } + }) + .catch(function () { + // Shape creation can fail silently + }); + })(lines[i]); + } + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to add position lines: ' + error.message, + }); + } +} + +// ============================================ +// Volume Helpers +// ============================================ +window.volumeStudyId = null; + +function createVolumeStudy() { + if (!window.chartWidget || !window.isChartReady) return; + if (window.volumeStudyId) return; + + try { + window.chartWidget + .activeChart() + .createStudy('Volume', false, false, {}, { 'volume ma.visible': false }) + .then(function (studyId) { + window.volumeStudyId = studyId; + }) + .catch(function () { + // Volume study creation failed + }); + } catch (e) { + // Not critical + } +} + +function handleToggleVolume(payload) { + if (!window.chartWidget || !window.isChartReady) return; + if (!payload) return; + + if (payload.visible && !window.volumeStudyId) { + createVolumeStudy(); + } else if (!payload.visible && window.volumeStudyId) { + try { + window.chartWidget.activeChart().removeEntity(window.volumeStudyId); + } catch (e) { + // Already removed + } + window.volumeStudyId = null; + } +} + +// ============================================ +// Custom Datafeed Implementation +// ============================================ + +/** + * TradingView variable_tick_size string. + * + * Tells TradingView to dynamically adjust pricescale/minmov based on + * the current price level. Format: "tickSize threshold tickSize threshold …" + * where each tickSize applies for prices below the next threshold, and + * the last tickSize applies to all prices above the last threshold. + * + * This replaces a manual pricescale computation and adapts automatically + * as prices change (e.g. meme token pumps from $0.0001 to $1). + */ +var VARIABLE_TICK_SIZE = [ + '0.0000000001', + '0.000001', // prices < $0.000001 → 10 dp + '0.00000001', + '0.0001', // prices < $0.0001 → 8 dp + '0.000001', + '0.01', // prices < $0.01 → 6 dp + '0.0001', + '1', // prices < $1 → 4 dp + '0.01', + '10000', // prices < $10000 → 2 dp + '0.1', // prices ≥ $10000 → 1 dp +].join(' '); + +function filterBarsForRange(fromMs, toMs, countBack) { + var barsInRange = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time >= fromMs && b.time < toMs) { + barsInRange.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + if (barsInRange.length < countBack) { + var allBeforeTo = []; + for (var j = 0; j < window.ohlcvData.length; j++) { + if (window.ohlcvData[j].time < toMs) { + allBeforeTo.push(window.ohlcvData[j]); + } + } + var startIdx = Math.max(0, allBeforeTo.length - countBack); + barsInRange = []; + for (var k = startIdx; k < allBeforeTo.length; k++) { + var bar = allBeforeTo[k]; + barsInRange.push({ + time: bar.time, + open: bar.open, + high: bar.high, + low: bar.low, + close: bar.close, + volume: bar.volume, + }); + } + } + + return barsInRange; +} + +function resolvePendingGetBars(pending) { + var currentOldest = + window.ohlcvData.length > 0 ? window.ohlcvData[0].time : 0; + + if (currentOldest >= pending.oldestAtDefer) { + pending.onResult([], { noData: true }); + return; + } + + // Return only the newly fetched bars (older than what we had before deferring). + // TradingView already has bars from oldestAtDefer onward. + var bars = []; + for (var i = 0; i < window.ohlcvData.length; i++) { + var b = window.ohlcvData[i]; + if (b.time < pending.oldestAtDefer) { + bars.push({ + time: b.time, + open: b.open, + high: b.high, + low: b.low, + close: b.close, + volume: b.volume, + }); + } + } + + pending.onResult(bars, { noData: false }); +} + +var customDatafeed = { + onReady: function (callback) { + setTimeout(function () { + callback({ + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + supports_marks: false, + supports_timescale_marks: false, + supports_time: true, + }); + }, 0); + }, + + searchSymbols: function (userInput, exchange, symbolType, onResult) { + onResult([]); + }, + + resolveSymbol: function (symbolName, onResolve) { + setTimeout(function () { + onResolve({ + name: symbolName, + ticker: symbolName, + description: symbolName, + type: 'crypto', + session: '24x7', + timezone: 'Etc/UTC', + exchange: '', + minmov: 1, + pricescale: 100, + variable_tick_size: VARIABLE_TICK_SIZE, + has_intraday: true, + has_daily: true, + has_weekly_and_monthly: true, + supported_resolutions: [ + '1', + '3', + '5', + '15', + '30', + '60', + '120', + '240', + '480', + '720', + '1D', + '3D', + '1W', + '1M', + ], + volume_precision: 0, + data_status: 'streaming', + }); + }, 0); + }, + + getBars: function (symbolInfo, resolution, periodParams, onResult, onError) { + try { + var fromMs = periodParams.from * 1000; + var toMs = periodParams.to * 1000; + var countBack = periodParams.countBack; + var firstRequest = periodParams.firstDataRequest; + + var bars = filterBarsForRange(fromMs, toMs, countBack); + + if (bars.length > 0) { + onResult(bars, { noData: false }); + return; + } + + if (firstRequest || window.ohlcvData.length === 0) { + onResult([], { noData: true }); + return; + } + + var oldestTs = window.ohlcvData[0].time; + + window.pendingGetBarsCallback = { + onResult: onResult, + oldestAtDefer: oldestTs, + }; + + sendToReactNative('NEED_MORE_HISTORY', { oldestTimestamp: oldestTs }); + } catch (error) { + onError(error.message); + } + }, + + subscribeBars: function (symbolInfo, resolution, onTick, listenerGuid) { + window.realtimeCallbacks[listenerGuid] = onTick; + }, + + unsubscribeBars: function (listenerGuid) { + delete window.realtimeCallbacks[listenerGuid]; + }, +}; + +// ============================================ +// Library Loading +// ============================================ +var libraryLoadAttempts = 0; +var maxLibraryLoadAttempts = 50; + +function loadLibrary() { + var scriptUrl = window.CONFIG.libraryUrl + 'charting_library.js'; + + var script = document.createElement('script'); + script.type = 'text/javascript'; + script.src = scriptUrl; + script.onload = function () { + window.libraryLoaded = true; + if (window.ohlcvData.length > 0) { + initChart(); + } + }; + script.onerror = function () { + window.libraryError = + 'Failed to load TradingView library. URL: ' + scriptUrl; + document.getElementById('loading-overlay').innerHTML = + '
' + + '

Failed to load chart library

' + + '

URL: ' + + scriptUrl + + '

' + + '

Check S3 access or CORS configuration.

' + + '
'; + sendToReactNative('ERROR', { message: window.libraryError }); + }; + document.head.appendChild(script); +} + +// ============================================ +// Chart Initialization +// ============================================ +function initChart() { + if (window.chartWidget) return; + + if (typeof TradingView === 'undefined') { + libraryLoadAttempts++; + if (libraryLoadAttempts >= maxLibraryLoadAttempts) { + var errorMsg = + 'TradingView library failed to initialize after ' + + maxLibraryLoadAttempts * 100 + + 'ms'; + document.getElementById('loading-overlay').textContent = errorMsg; + sendToReactNative('ERROR', { message: errorMsg }); + return; + } + setTimeout(initChart, 100); + return; + } + + if (window.ohlcvData.length === 0) { + return; + } + + try { + var theme = window.CONFIG.theme; + var features = window.CONFIG.features || {}; + + // Disabled features are passed from React Native via CONFIG.features.disabledFeatures. + // Defaults are set in DEFAULT_DISABLED_FEATURES (AdvancedChart.types.ts) and are + // optimized for the Token Details mobile UX. Consumers needing TradingView's + // native UI (e.g. Perps) can override via the disabledFeatures prop. + var disabledFeatures = (features.disabledFeatures || []).slice(); + + if (!features.enableDrawingTools) { + disabledFeatures.push('left_toolbar'); + disabledFeatures.push('context_menus'); + } + + window.chartWidget = new TradingView.widget({ + symbol: window.currentSymbol, + interval: window.currentResolution || '5', + container: 'tv_chart_container', + datafeed: customDatafeed, + library_path: window.CONFIG.libraryUrl, + locale: 'en', + fullscreen: false, + autosize: true, + theme: 'Dark', + + disabled_features: disabledFeatures, + enabled_features: ['study_templates', 'iframe_loading_same_origin'], + + overrides: { + 'paneProperties.background': theme.backgroundColor, + 'paneProperties.backgroundType': 'solid', + 'paneProperties.vertGridProperties.color': theme.borderColor, + 'paneProperties.horzGridProperties.color': theme.borderColor, + 'scalesProperties.textColor': theme.textColor, + 'scalesProperties.lineColor': theme.borderColor, + 'scalesProperties.fontSize': 11, + 'scalesProperties.showStudyLastValue': true, + 'scalesProperties.showSeriesLastValue': true, + 'scalesProperties.showSymbolLabels': true, + 'scalesProperties.showRightScale': true, + 'scalesProperties.showLeftScale': false, + 'paneProperties.bottomMargin': 5, + 'mainSeriesProperties.candleStyle.upColor': theme.successColor, + 'mainSeriesProperties.candleStyle.downColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.borderUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.borderDownColor': theme.errorColor, + 'mainSeriesProperties.candleStyle.wickUpColor': theme.successColor, + 'mainSeriesProperties.candleStyle.wickDownColor': theme.errorColor, + }, + + loading_screen: { + backgroundColor: theme.backgroundColor, + foregroundColor: theme.primaryColor, + }, + }); + + window.chartWidget.onChartReady(function () { + window.isChartReady = true; + document.getElementById('loading-overlay').classList.add('hidden'); + + try { + var timeScale = window.chartWidget.activeChart().getTimeScale(); + timeScale.defaultRightOffset().setValue(0); + timeScale.setRightOffset(0); + } catch (e) {} + + sendToReactNative('CHART_READY', {}); + + // Set up crosshair move listener for OHLC overlay + try { + window.chartWidget + .activeChart() + .crossHairMoved() + .subscribe(null, function (params) { + if ( + params && + params.price !== undefined && + params.time !== undefined + ) { + // Find the bar closest to the crosshair time + var targetTime = params.time * 1000; + var closestBar = null; + var minDiff = Infinity; + for (var i = 0; i < window.ohlcvData.length; i++) { + var diff = Math.abs(window.ohlcvData[i].time - targetTime); + if (diff < minDiff) { + minDiff = diff; + closestBar = window.ohlcvData[i]; + } + } + if (closestBar) { + sendToReactNative('CROSSHAIR_MOVE', { + data: { + time: closestBar.time, + open: closestBar.open, + high: closestBar.high, + low: closestBar.low, + close: closestBar.close, + volume: closestBar.volume, + }, + }); + } + } else { + sendToReactNative('CROSSHAIR_MOVE', { data: null }); + } + }); + } catch (e) { + // Crosshair subscription not critical + } + + // Auto-add volume study if showVolume is true (no SMA overlay) + if (features.showVolume) { + createVolumeStudy(); + } + + // Process pending messages + window.pendingMessages.forEach(function (msg) { + handleMessage({ data: msg }); + }); + window.pendingMessages = []; + }); + } catch (error) { + sendToReactNative('ERROR', { + message: 'Failed to initialize chart: ' + error.message, + }); + } +} + +// ============================================ +// Start +// ============================================ +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + loadLibrary(); + }); +} else { + loadLibrary(); +} +`; diff --git a/app/components/UI/Charts/AdvancedChart/webview/index.ts b/app/components/UI/Charts/AdvancedChart/webview/index.ts new file mode 100644 index 00000000000..4bc83b6113b --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/index.ts @@ -0,0 +1 @@ +export { default as chartLogicScript } from './chartLogicString'; diff --git a/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js new file mode 100644 index 00000000000..78ff071c1b8 --- /dev/null +++ b/app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js @@ -0,0 +1,31 @@ +#!/usr/bin/env node +/* eslint-disable import/no-commonjs, import/no-nodejs-modules, no-console */ +/** + * Sync script that reads chartLogic.js and exports it as a string in chartLogicString.ts + * + * Run this after editing chartLogic.js: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +const fs = require('fs'); +const path = require('path'); + +const sourceFile = path.join(__dirname, 'chartLogic.js'); +const targetFile = path.join(__dirname, 'chartLogicString.ts'); + +const jsContent = fs.readFileSync(sourceFile, 'utf8'); + +const tsContent = `/** + * AUTO-GENERATED - DO NOT EDIT DIRECTLY + * + * This file is generated from chartLogic.js by syncChartLogic.js + * Edit chartLogic.js instead, then run: + * node app/components/UI/Charts/AdvancedChart/webview/syncChartLogic.js + */ + +// eslint-disable-next-line import/no-default-export +export default \`${jsContent.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\${/g, '\\${')}\`; +`; + +fs.writeFileSync(targetFile, tsContent); +console.log('✓ Synced chartLogic.js → chartLogicString.ts'); From 09a6206b0059a0bb6bb1361e62e8ac67c394c102 Mon Sep 17 00:00:00 2001 From: Christopher Ferreira <104831203+christopherferreira9@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:01:31 +0000 Subject: [PATCH 06/10] test: create Unified Gestures (#26932) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** - Adds a unified gesture layer using the **Strategy pattern** (`GestureStrategy` interface with `DetoxGestureStrategy` and `AppiumGestureStrategy` implementations), allowing page objects to execute gestures without knowing which framework is running - Adds `UnifiedGestures` static facade and `encapsulatedAction()` helper for the rare cases where Detox and Appium need structurally different flows - Centralizes test documentation into `tests/docs/` alongside existing guides (`MOCKING.md`, `CONTROLLER_MOCKING.md`, etc.) ## Changes | File | What | |------|------| | `tests/framework/GestureStrategy.ts` | New — `GestureStrategy` interface + `DetoxGestureStrategy` and `AppiumGestureStrategy` implementations | | `tests/framework/UnifiedGestures.ts` | New — Static facade that delegates to the active strategy | | `tests/framework/encapsulatedAction.ts` | New — Helper for framework-branching action logic | | `tests/docs/UNIFIED_GESTURES_MIGRATION.md` | New — Migration guide for adopting `UnifiedGestures` in page objects | | `tests/docs/UNIFIED_E2E_ARCHITECTURE.md` | Moved from `tests/framework/` (also fixed filename typo: `ARCHIITECTURE` → `ARCHITECTURE`) | ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MMQA-1544 ## **Manual testing steps** N/A ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **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] > **Low Risk** > Adds new test-only abstraction layers and helpers without changing existing `Gestures` behavior; risk is mainly limited to new unified APIs and their validation/edge-case handling (e.g., `scrollToElement` matcher type checks and `tapAtIndex` bounds checks). > > **Overview** > Adds a new **framework-agnostic gesture API** via `UnifiedGestures`, backed by a `GestureStrategy` interface with `DetoxGestureStrategy` (delegates to existing `Gestures` while mapping `timeout`/`description`) and `AppiumGestureStrategy` (delegates to `PlaywrightElement`/`PlaywrightGestures`). > > Extends Appium gesture support with a native-touch `PlaywrightGestures.dblTap`, adds an `encapsulatedAction()` escape hatch for framework-divergent flows, and exports the new APIs from `tests/framework/index.ts`. > > Adds unit coverage for key strategy behaviors (`scrollToElement` forwarding + matcher validation, `dblTap` delegation, and `tapAtIndex` array/index handling) and includes a migration guide under `tests/docs/`. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 12296cc7eba37f0e5b2c2292bf0d8dda0ee1eb6c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../UNIFIED_E2E_ARCHITECTURE.md} | 0 tests/docs/UNIFIED_GESTURES_MIGRATION.md | 205 ++++++++ tests/framework/AppiumGestureStrategy.test.ts | 88 ++++ tests/framework/GestureStrategy.test.ts | 63 +++ tests/framework/GestureStrategy.ts | 442 ++++++++++++++++++ tests/framework/PlaywrightGestures.ts | 25 + tests/framework/UnifiedGestures.ts | 127 +++++ tests/framework/encapsulatedAction.ts | 36 ++ tests/framework/index.ts | 10 + 9 files changed, 996 insertions(+) rename tests/{framework/UNIFIED_E2E_ARCHIITECTURE.md => docs/UNIFIED_E2E_ARCHITECTURE.md} (100%) create mode 100644 tests/docs/UNIFIED_GESTURES_MIGRATION.md create mode 100644 tests/framework/AppiumGestureStrategy.test.ts create mode 100644 tests/framework/GestureStrategy.test.ts create mode 100644 tests/framework/GestureStrategy.ts create mode 100644 tests/framework/UnifiedGestures.ts create mode 100644 tests/framework/encapsulatedAction.ts diff --git a/tests/framework/UNIFIED_E2E_ARCHIITECTURE.md b/tests/docs/UNIFIED_E2E_ARCHITECTURE.md similarity index 100% rename from tests/framework/UNIFIED_E2E_ARCHIITECTURE.md rename to tests/docs/UNIFIED_E2E_ARCHITECTURE.md diff --git a/tests/docs/UNIFIED_GESTURES_MIGRATION.md b/tests/docs/UNIFIED_GESTURES_MIGRATION.md new file mode 100644 index 00000000000..25c7b4e5149 --- /dev/null +++ b/tests/docs/UNIFIED_GESTURES_MIGRATION.md @@ -0,0 +1,205 @@ +# Unified Gestures Migration Guide + +## Overview + +`UnifiedGestures` is a static facade that lets page objects execute gestures without knowing whether Detox or Appium/WebdriverIO is running. It uses the **Strategy pattern**: a single `GestureStrategy` interface with two implementations (`DetoxGestureStrategy` and `AppiumGestureStrategy`), selected once at startup. + +``` +Page Objects → UnifiedGestures (static) → GestureStrategy (interface) + ├── DetoxGestureStrategy → Gestures (existing) + └── AppiumGestureStrategy → PlaywrightElement / PlaywrightGestures +``` + +This is the **action** counterpart to `encapsulated()` (which handles element locators). Together they let you write fully framework-agnostic page objects. + +## Available Methods + +| Method | Description | +| -------------------------------------------- | ------------------------------------------------------------------------------ | +| `tap(elem, opts?)` | Tap an element | +| `waitAndTap(elem, opts?)` | Wait for visibility then tap | +| `typeText(elem, text, opts?)` | Clear field and type text | +| `replaceText(elem, text, opts?)` | Replace existing text | +| `swipe(elem, direction, opts?)` | Swipe in a direction | +| `scrollToElement(target, scrollView, opts?)` | Scroll until target is visible | +| `longPress(elem, opts?)` | Long press an element | +| `dblTap(elem, opts?)` | Double tap an element | +| `tapAtPoint(elem, point, opts?)` | Tap at specific {x, y} coordinates on an element | +| `tapAtIndex(elem, index, opts?)` | Tap the nth matching element (accepts single element or `PlaywrightElement[]`) | + +All methods accept optional `UnifiedGestureOptions`: + +```typescript +interface UnifiedGestureOptions { + timeout?: number; // Max wait time in ms + description?: string; // For logging / error messages +} +``` + +## Migration Steps + +### 1. Update element getters to use `encapsulated()` + +If your page object still uses Detox-only matchers, convert them first: + +```typescript +// Before +get passwordInput(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT); +} + +// After +get passwordInput(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT), + appium: () => PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT), + }); +} +``` + +### 2. Replace `Gestures.*` calls with `UnifiedGestures.*` + +```typescript +// Before +import { Gestures } from '../../framework'; + +async enterPassword(password: string) { + await Gestures.typeText(this.passwordInput, password, { + hideKeyboard: true, + elemDescription: 'password field', + }); +} + +// After +import { UnifiedGestures } from '../../framework'; + +async enterPassword(password: string) { + await UnifiedGestures.typeText(this.passwordInput, password, { + description: 'password field', + }); +} +``` + +Framework-specific options like `hideKeyboard`, `checkStability`, and `clearFirst` are handled internally by each strategy with sensible defaults: + +- **Detox**: `hideKeyboard: true`, `clearFirst: true`, retry + stability checks +- **Appium**: Direct `PlaywrightElement` / `PlaywrightGestures` calls (e.g. `fill()`, `click()`) + +### 3. Handle edge cases with `encapsulatedAction()` + +For the rare ~3% of methods where Detox and Appium need structurally different flows: + +```typescript +import { encapsulatedAction } from '../../framework'; + +async dismissOnboarding() { + await encapsulatedAction({ + detox: async () => { + await Gestures.swipe(this.overlay, 'up'); + await Gestures.tap(this.dismissButton); + }, + appium: async () => { + const btn = await asPlaywrightElement(this.dismissButton); + await btn.click(); + }, + }); +} +``` + +## Escape Hatch + +When the unified approach becomes overly complex for a specific case, you can always use `FrameworkDetector` or `PlatformDetector` directly for custom conditional logic: + +```typescript +import { FrameworkDetector } from '../../framework'; + +async complexSpecialCase() { + if (FrameworkDetector.isDetox()) { + // Detox-specific multi-step flow + } else { + // Appium-specific multi-step flow + } +} +``` + +This should be the last resort. Prefer `UnifiedGestures` > `encapsulatedAction()` > direct `FrameworkDetector` checks, in that order. + +## Full Before/After Example + +### Before (Detox-only) + +```typescript +import { Gestures, Matchers } from '../../framework'; +import { LoginViewSelectors } from '../../selectors/LoginView.selectors'; + +class LoginView { + get passwordInput(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT); + } + + get loginButton(): DetoxElement { + return Matchers.getElementByID(LoginViewSelectors.LOGIN_BUTTON); + } + + async login(password: string) { + await Gestures.typeText(this.passwordInput, password, { + hideKeyboard: true, + elemDescription: 'password input', + }); + await Gestures.waitAndTap(this.loginButton, { + elemDescription: 'login button', + }); + } +} +``` + +### After (Unified) + +```typescript +import { UnifiedGestures } from '../../framework'; +import { + encapsulated, + EncapsulatedElementType, + Matchers, + PlaywrightMatchers, +} from '../../framework'; +import { LoginViewSelectors } from '../../selectors/LoginView.selectors'; + +class LoginView { + get passwordInput(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.PASSWORD_INPUT), + appium: () => + PlaywrightMatchers.getElementById(LoginViewSelectors.PASSWORD_INPUT), + }); + } + + get loginButton(): EncapsulatedElementType { + return encapsulated({ + detox: () => Matchers.getElementByID(LoginViewSelectors.LOGIN_BUTTON), + appium: () => + PlaywrightMatchers.getElementById(LoginViewSelectors.LOGIN_BUTTON), + }); + } + + async login(password: string) { + await UnifiedGestures.typeText(this.passwordInput, password, { + description: 'password input', + }); + await UnifiedGestures.waitAndTap(this.loginButton, { + description: 'login button', + }); + } +} +``` + +## FAQ + +**Q: Does this change existing Detox test behavior?** +No. `DetoxGestureStrategy` wraps the existing `Gestures` class — all retry logic, stability checks, and platform-specific scroll behavior are preserved. + +**Q: What if I need a Detox-specific option like `checkStability`?** +Use `encapsulatedAction()` and call `Gestures` directly in the Detox branch. + +**Q: Can I still use `Gestures` directly?** +Yes. The `Gestures` class is not removed or modified. For Detox-only page objects that haven't been migrated, `Gestures` works exactly as before. diff --git a/tests/framework/AppiumGestureStrategy.test.ts b/tests/framework/AppiumGestureStrategy.test.ts new file mode 100644 index 00000000000..3939f6145ce --- /dev/null +++ b/tests/framework/AppiumGestureStrategy.test.ts @@ -0,0 +1,88 @@ +jest.mock('./PlaywrightGestures.ts', () => ({ + __esModule: true, + default: { + dblTap: jest.fn(), + }, +})); + +jest.mock('./EncapsulatedElement.ts', () => ({ + asPlaywrightElement: jest.fn(), +})); + +import PlaywrightGestures from './PlaywrightGestures.ts'; +import { asPlaywrightElement } from './EncapsulatedElement.ts'; +import { AppiumGestureStrategy } from './GestureStrategy.ts'; +import { PlaywrightElement } from './PlaywrightAdapter.ts'; + +describe('AppiumGestureStrategy.dblTap', () => { + const strategy = new AppiumGestureStrategy(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates to PlaywrightGestures.dblTap with resolved element', async () => { + const elem = Promise.resolve({}) as never; + const playwrightElement = { unwrap: jest.fn() } as never; + (asPlaywrightElement as jest.Mock).mockResolvedValue(playwrightElement); + + await strategy.dblTap(elem); + + expect(asPlaywrightElement).toHaveBeenCalledWith(elem); + expect(PlaywrightGestures.dblTap).toHaveBeenCalledWith(playwrightElement); + }); +}); + +describe('AppiumGestureStrategy.tapAtIndex', () => { + const strategy = new AppiumGestureStrategy(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + const createPlaywrightElement = (): PlaywrightElement => + ({ + click: jest.fn(), + unwrap: jest.fn(), + }) as unknown as PlaywrightElement; + + it('clicks indexed element when PlaywrightElement array is provided', async () => { + const first = createPlaywrightElement(); + const second = createPlaywrightElement(); + const third = createPlaywrightElement(); + + await strategy.tapAtIndex([first, second, third], 2); + + expect(third.click).toHaveBeenCalledTimes(1); + expect(second.click).not.toHaveBeenCalled(); + expect(first.click).not.toHaveBeenCalled(); + expect(asPlaywrightElement).not.toHaveBeenCalled(); + }); + + it('throws when array index is out of bounds', async () => { + const only = createPlaywrightElement(); + + await expect(strategy.tapAtIndex([only], 2)).rejects.toThrow( + 'tapAtIndex: index 2 is out of bounds (1 elements)', + ); + }); + + it('throws for single element when index is greater than zero', async () => { + const elem = Promise.resolve({}) as never; + + await expect(strategy.tapAtIndex(elem, 2)).rejects.toThrow( + 'tapAtIndex: Appium requires a PlaywrightElement[] array for index > 0.', + ); + }); + + it('uses single element pass-through when index is zero', async () => { + const elem = Promise.resolve({}) as never; + const playwrightElement = createPlaywrightElement(); + (asPlaywrightElement as jest.Mock).mockResolvedValue(playwrightElement); + + await strategy.tapAtIndex(elem, 0); + + expect(asPlaywrightElement).toHaveBeenCalledWith(elem); + expect(playwrightElement.click).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/framework/GestureStrategy.test.ts b/tests/framework/GestureStrategy.test.ts new file mode 100644 index 00000000000..4d488a61b38 --- /dev/null +++ b/tests/framework/GestureStrategy.test.ts @@ -0,0 +1,63 @@ +jest.mock('./Gestures.ts', () => ({ + __esModule: true, + default: { + scrollToElement: jest.fn(), + }, +})); + +import Gestures from './Gestures.ts'; +import { DetoxGestureStrategy } from './GestureStrategy.ts'; + +describe('DetoxGestureStrategy.scrollToElement', () => { + const strategy = new DetoxGestureStrategy(); + + const createDetoxElement = (): DetoxElement => + ({ tap: jest.fn() }) as unknown as DetoxElement; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('forwards matcher scrollView to Gestures.scrollToElement', async () => { + const target = createDetoxElement(); + const matcher = { + type: 'id', + value: 'scroll-view', + } as unknown as Detox.NativeMatcher; + const scrollView = Promise.resolve(matcher); + + await strategy.scrollToElement(target, scrollView, { + timeout: 1000, + description: 'scroll to token', + }); + + expect(Gestures.scrollToElement).toHaveBeenCalledTimes(1); + + const [forwardedTarget, forwardedScrollView, forwardedOpts] = ( + Gestures.scrollToElement as jest.Mock + ).mock.calls[0]; + + expect(forwardedTarget).toBe(target); + await expect(forwardedScrollView).resolves.toBe(matcher); + expect(forwardedOpts).toEqual( + expect.objectContaining({ + timeout: 1000, + elemDescription: 'scroll to token', + }), + ); + }); + + it('rejects DetoxElement passed as scrollView', async () => { + const target = createDetoxElement(); + const invalidScrollView = createDetoxElement(); + + await expect( + strategy.scrollToElement( + target, + invalidScrollView as unknown as Promise, + ), + ).rejects.toThrow( + 'DetoxGestureStrategy.scrollToElement requires a Detox NativeMatcher', + ); + }); +}); diff --git a/tests/framework/GestureStrategy.ts b/tests/framework/GestureStrategy.ts new file mode 100644 index 00000000000..3e4c5c78c50 --- /dev/null +++ b/tests/framework/GestureStrategy.ts @@ -0,0 +1,442 @@ +import Gestures from './Gestures.ts'; +import PlaywrightGestures from './PlaywrightGestures.ts'; +import { PlaywrightElement } from './PlaywrightAdapter.ts'; +import { + EncapsulatedElementType, + asDetoxElement, + asPlaywrightElement, +} from './EncapsulatedElement.ts'; + +/** + * Unified options for gesture methods. + * Framework-specific options (e.g. Detox's checkStability, hideKeyboard) are + * handled internally by each strategy — page objects only deal with these + * universal options. + */ +export interface UnifiedGestureOptions { + /** Maximum time (ms) to wait for the element before timing out */ + timeout?: number; + /** Human-readable description for logging and error messages */ + description?: string; +} + +/** + * Element input for tapAtIndex — either a single element (Detox uses .atIndex()) + * or an array of elements (Appium selects by array index). + */ +export type TapAtIndexElement = EncapsulatedElementType | PlaywrightElement[]; +export type ScrollViewMatcher = Promise; + +/** + * Strategy interface for framework-agnostic gesture execution. + * + * Each method accepts an `EncapsulatedElementType` (either DetoxElement or + * Promise) so page objects never need to know which + * framework is running. + */ +export interface GestureStrategy { + tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise; + + replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise; + + swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise; + + scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise; + + longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise; + + tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise; + + tapAtIndex( + elem: TapAtIndexElement, + index: number, + opts?: UnifiedGestureOptions, + ): Promise; +} + +/** + * Detox implementation of GestureStrategy. + * + * Wraps the existing `Gestures` class, preserving all retry logic, stability + * checks, and platform-specific scroll behaviour. `UnifiedGestureOptions` are + * mapped to Detox-specific option shapes internally. + */ +export class DetoxGestureStrategy implements GestureStrategy { + /** + * Tap an element + * @param elem - The element to tap + * @param opts - The options for the tap + * @returns A promise that resolves when the tap is complete + */ + async tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Wait for an element to be visible and then tap it + * @param elem - The element to wait and tap + * @param opts - The options for the wait and tap + * @returns A promise that resolves when the wait and tap is complete + */ + async waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.waitAndTap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Type text into an element + * @param elem - The element to type text into + * @param text - The text to type + * @param opts - The options for the type text + * @returns A promise that resolves when the type text is complete + */ + async typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.typeText(asDetoxElement(elem), text, { + hideKeyboard: true, + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Replace text in an element + * @param elem - The element to replace text in + * @param text - The text to replace + * @param opts - The options for the replace text + * @returns A promise that resolves when the replace text is complete + */ + async replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.replaceText(asDetoxElement(elem), text, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Swipe an element + * @param elem - The element to swipe + * @param direction - The direction to swipe + * @param opts - The options for the swipe + * @returns A promise that resolves when the swipe is complete + */ + async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.swipe(asDetoxElement(elem), direction, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Scroll to an element + * @param target - The element to scroll to + * @param scrollView - The scroll view to scroll to + * @param opts - The options for the scroll to element + * @returns A promise that resolves when the scroll to element is complete + */ + async scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise { + const resolvedScrollView = await scrollView; + + if (this.isLikelyDetoxElement(resolvedScrollView)) { + throw new Error( + 'DetoxGestureStrategy.scrollToElement requires a Detox NativeMatcher ' + + '(e.g. Matchers.getIdentifier(...) or by.id(...)), not a DetoxElement.', + ); + } + + await Gestures.scrollToElement( + asDetoxElement(target), + Promise.resolve(resolvedScrollView), + { + timeout: opts?.timeout, + elemDescription: opts?.description, + }, + ); + } + + private isLikelyDetoxElement(value: unknown): value is DetoxElement { + return ( + typeof value === 'object' && + value !== null && + 'tap' in value && + typeof (value as { tap?: unknown }).tap === 'function' + ); + } + + /** + * Long press an element + * @param elem - The element to long press + * @param opts - The options for the long press + * @returns A promise that resolves when the long press is complete + */ + async longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.longPress(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Double tap an element + * @param elem - The element to double tap + * @param opts - The options for the double tap + * @returns A promise that resolves when the double tap is complete + */ + async dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.dblTap(asDetoxElement(elem), { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Tap at a point on an element + * @param elem - The element to tap at a point on + * @param point - The point to tap at + * @param opts - The options for the tap at point + * @returns A promise that resolves when the tap at point is complete + */ + async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tapAtPoint(asDetoxElement(elem), point, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } + + /** + * Tap at an index on an element + * @param elem - The element to tap at an index on + * @param index - The index to tap at + * @param opts - The options for the tap at index + * @returns A promise that resolves when the tap at index is complete + */ + async tapAtIndex( + elem: EncapsulatedElementType, + index: number, + opts?: UnifiedGestureOptions, + ): Promise { + await Gestures.tapAtIndex(asDetoxElement(elem), index, { + timeout: opts?.timeout, + elemDescription: opts?.description, + }); + } +} + +/** + * Appium/WebdriverIO implementation of GestureStrategy. + * + * Wraps `PlaywrightElement` and `PlaywrightGestures`. + */ +export class AppiumGestureStrategy implements GestureStrategy { + /** + * Tap an element + * @param elem - The element to tap + * @returns A promise that resolves when the tap is complete + */ + async tap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await el.click(); + } + + /** + * Wait for an element to be visible and then tap it + * @param elem - The element to wait and tap + * @returns A promise that resolves when the wait and tap is complete + */ + async waitAndTap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await el.click(); + } + + /** + * Type text into an element + * @param elem - The element to type text into + * @param text - The text to type + * @returns A promise that resolves when the type text is complete + */ + async typeText(elem: EncapsulatedElementType, text: string): Promise { + const el = await asPlaywrightElement(elem); + await el.fill(text); + } + + /** + * Replace text in an element + * @param elem - The element to replace text in + * @param text - The text to replace + * @returns A promise that resolves when the replace text is complete + */ + async replaceText( + elem: EncapsulatedElementType, + text: string, + ): Promise { + const el = await asPlaywrightElement(elem); + await el.clear(); + await el.fill(text); + } + + /** + * Swipe an element + * @param elem - The element to swipe + * @param direction - The direction to swipe + * @returns A promise that resolves when the swipe is complete + */ + async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + ): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.swipe(el, direction); + } + + /** + * Scroll to an element + * @param target - The element to scroll to + * @param scrollView - The scroll view to scroll to + * @returns A promise that resolves when the scroll to element is complete + */ + async scrollToElement( + target: EncapsulatedElementType, + _scrollView: ScrollViewMatcher, + ): Promise { + const el = await asPlaywrightElement(target); + await PlaywrightGestures.scrollIntoView(el); + } + + /** + * Long press an element + * @param elem - The element to long press + * @returns A promise that resolves when the long press is complete + */ + async longPress(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.longPress(el); + } + + /** + * Double tap an element + * @param elem - The element to double tap + * @returns A promise that resolves when the double tap is complete + */ + async dblTap(elem: EncapsulatedElementType): Promise { + const el = await asPlaywrightElement(elem); + await PlaywrightGestures.dblTap(el); + } + + /** + * Tap at a point on an element + * @param elem - The element to tap at a point on + * @param point - The point to tap at + * @returns A promise that resolves when the tap at point is complete + */ + async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + ): Promise { + const el = await asPlaywrightElement(elem); + await el.tapOnCoordinates(point); + } + + /** + * Tap at an index on an element + * @param elem - The element to tap at an index on + * @param index - The index to tap at + * @returns A promise that resolves when the tap at index is complete + */ + async tapAtIndex(elem: TapAtIndexElement, index: number): Promise { + // If an array of PlaywrightElements is provided, tap the one at `index` + if (Array.isArray(elem)) { + const elements = elem as PlaywrightElement[]; + if (index < 0 || index >= elements.length) { + throw new Error( + `tapAtIndex: index ${index} is out of bounds (${elements.length} elements)`, + ); + } + await elements[index].click(); + return; + } + + // Single element: allow index 0 as a pass-through, reject anything else + if (index !== 0) { + throw new Error( + `tapAtIndex: Appium requires a PlaywrightElement[] array for index > 0. ` + + `Received single element with index ${index}.`, + ); + } + const el = await asPlaywrightElement(elem); + await el.click(); + } +} diff --git a/tests/framework/PlaywrightGestures.ts b/tests/framework/PlaywrightGestures.ts index 441d8ee3c5d..07fd17abd6a 100644 --- a/tests/framework/PlaywrightGestures.ts +++ b/tests/framework/PlaywrightGestures.ts @@ -82,6 +82,31 @@ export default class PlaywrightGestures { ]); } + /** + * Double tap an element using native touch actions. + * + * Using explicit touchAction avoids relying on desktop-oriented click + * semantics and keeps both taps within a mobile-appropriate interval. + */ + @boxedStep + static async dblTap(elem: PlaywrightElement, intervalMs = 60): Promise { + const location = await elem.unwrap().getLocation(); + const size = await elem.unwrap().getSize(); + + const x = location.x + size.width / 2; + const y = location.y + size.height / 2; + + await elem + .unwrap() + .touchAction([ + { action: 'press', x, y }, + 'release', + { action: 'wait', ms: intervalMs }, + { action: 'press', x, y }, + 'release', + ]); + } + /** * Scroll element into view */ diff --git a/tests/framework/UnifiedGestures.ts b/tests/framework/UnifiedGestures.ts new file mode 100644 index 00000000000..381b2b033e9 --- /dev/null +++ b/tests/framework/UnifiedGestures.ts @@ -0,0 +1,127 @@ +import { FrameworkDetector } from './FrameworkDetector.ts'; +import { EncapsulatedElementType } from './EncapsulatedElement.ts'; +import { + GestureStrategy, + UnifiedGestureOptions, + TapAtIndexElement, + ScrollViewMatcher, + DetoxGestureStrategy, + AppiumGestureStrategy, +} from './GestureStrategy.ts'; + +/** + * UnifiedGestures — Static facade for framework-agnostic gesture execution. + * + * The framework strategy is resolved **once** on first use and cached for the + * lifetime of the test run. Page objects call these static methods directly + * and never need to know whether Detox or Appium is running. + * + * @example + * ```typescript + * import { UnifiedGestures } from '../framework'; + * + * class LoginView { + * get passwordInput(): EncapsulatedElementType { ... } + * + * async enterPassword(password: string) { + * await UnifiedGestures.typeText(this.passwordInput, password); + * } + * } + * ``` + */ +export default class UnifiedGestures { + private static _strategy: GestureStrategy | null = null; + + /** Lazily resolve and cache the active strategy */ + private static get strategy(): GestureStrategy { + if (!this._strategy) { + this._strategy = FrameworkDetector.isDetox() + ? new DetoxGestureStrategy() + : new AppiumGestureStrategy(); + } + return this._strategy; + } + + /** Reset the cached strategy (useful in tests) */ + static resetStrategy(): void { + this._strategy = null; + } + + // ── Gesture Methods ───────────────────────────────────────── + + static async tap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tap(elem, opts); + } + + static async waitAndTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.waitAndTap(elem, opts); + } + + static async typeText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.typeText(elem, text, opts); + } + + static async replaceText( + elem: EncapsulatedElementType, + text: string, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.replaceText(elem, text, opts); + } + + static async swipe( + elem: EncapsulatedElementType, + direction: 'up' | 'down' | 'left' | 'right', + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.swipe(elem, direction, opts); + } + + static async scrollToElement( + target: EncapsulatedElementType, + scrollView: ScrollViewMatcher, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.scrollToElement(target, scrollView, opts); + } + + static async longPress( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.longPress(elem, opts); + } + + static async dblTap( + elem: EncapsulatedElementType, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.dblTap(elem, opts); + } + + static async tapAtPoint( + elem: EncapsulatedElementType, + point: { x: number; y: number }, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tapAtPoint(elem, point, opts); + } + + static async tapAtIndex( + elem: TapAtIndexElement, + index: number, + opts?: UnifiedGestureOptions, + ): Promise { + await this.strategy.tapAtIndex(elem, index, opts); + } +} diff --git a/tests/framework/encapsulatedAction.ts b/tests/framework/encapsulatedAction.ts new file mode 100644 index 00000000000..7a5e1d8d59f --- /dev/null +++ b/tests/framework/encapsulatedAction.ts @@ -0,0 +1,36 @@ +import { FrameworkDetector } from './FrameworkDetector.ts'; + +/** + * Escape hatch for page-object methods that need entirely different + * control flow per framework (~3% of cases). + * + * Use `UnifiedGestures` for the common case. Reach for `encapsulatedAction` + * only when the Detox and Appium flows differ structurally (e.g. different + * sequences of taps, waits, or scrolls). + * + * @example + * ```typescript + * async dismissOnboarding() { + * await encapsulatedAction({ + * detox: async () => { + * await Gestures.swipe(this.overlay, 'up'); + * await Gestures.tap(this.dismissButton); + * }, + * appium: async () => { + * const btn = await asPlaywrightElement(this.dismissButton); + * await btn.click(); + * }, + * }); + * } + * ``` + */ +export async function encapsulatedAction(config: { + detox: () => Promise; + appium: () => Promise; +}): Promise { + if (FrameworkDetector.isDetox()) { + await config.detox(); + } else { + await config.appium(); + } +} diff --git a/tests/framework/index.ts b/tests/framework/index.ts index 665982796ed..a70108f209d 100644 --- a/tests/framework/index.ts +++ b/tests/framework/index.ts @@ -38,3 +38,13 @@ export { export { FrameworkDetector, TestFramework } from './FrameworkDetector.ts'; export { PlatformDetector } from './PlatformLocator.ts'; +export { default as UnifiedGestures } from './UnifiedGestures.ts'; +export { encapsulatedAction } from './encapsulatedAction.ts'; +export { + DetoxGestureStrategy, + AppiumGestureStrategy, + type GestureStrategy, + type UnifiedGestureOptions, + type TapAtIndexElement, + type ScrollViewMatcher, +} from './GestureStrategy.ts'; From b93aa06002ce968f596aeb1b63905651e94ff933 Mon Sep 17 00:00:00 2001 From: Xiaoming Wang <7315988+dawnseeker8@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:13:12 +0800 Subject: [PATCH 07/10] fix: request camera permission on Android during QR transaction signing (#26415) Previously, the QR signing flow only called PermissionsAndroid.check() to verify camera access. When a user had selected "only this time" for camera permission, Android revokes the grant on app background. On next launch, check() returns false, disabling the "Get signature" button with no way to re-grant permission from within the app. Now both useCamera.ts and QRSigningDetails.tsx follow a check-then-request pattern: if check() returns false, request() is called to trigger the system permission dialog, matching the behavior of the "Add QR wallet" flow which already used requestPermission() from react-native-vision-camera. Fixes #26115 ## **Description** ## **Changelog** CHANGELOG entry: fix camera permission `allow once` didn't show the camera popup in android. ## **Related issues** Fixes: #26115 ## **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** - [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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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 camera-permission gating in the QR signing flow (always enabling the confirm button and moving permission handling into `AnimatedQRScannerModal`), which can affect Android UX and scanning availability if the permission lifecycle has edge cases. > > **Overview** > Fixes Android QR transaction signing when camera permission is revoked after backgrounding by **moving permission handling fully into** `AnimatedQRScannerModal`. > > The scanner now re-requests permission when the app returns to the foreground and, if permission remains denied, keeps the modal open showing a *no-permission* state with an **Open Settings** button (`Linking.openSettings`) instead of immediately erroring out. > > Removes pre-check/pre-request Android permission logic (`PermissionsAndroid`) from `QRSigningDetails`/`useCamera`, so the "Get signature"/confirm button is no longer disabled for camera permission reasons; related props and tests are updated, plus new `qr_scanner.open_settings` translations and small smoke-test assertion/scrolling robustness tweaks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit eede7fe8a78a72d5431bee35c33d1c3eb09aa7e7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --------- Co-authored-by: Cursor Agent Co-authored-by: Xiaoming Wang Co-authored-by: Olivier-BB --- .../UI/QRHardware/AnimatedQRScanner.test.tsx | 80 +++++++++++- .../UI/QRHardware/AnimatedQRScanner.tsx | 85 +++++++++++-- .../UI/QRHardware/QRSigningDetails.tsx | 73 +---------- .../UI/QRHardware/QRSigningModal/index.tsx | 1 - .../QRSigningTransactionModal.test.tsx | 3 - .../QRHardware/QRSigningTransactionModal.tsx | 1 - .../qr-hardware-context.test.tsx | 6 +- .../qr-hardware-context/useCamera.test.ts | 21 +-- .../context/qr-hardware-context/useCamera.ts | 120 +++--------------- locales/languages/hi-in.json | 1 + locales/languages/id-id.json | 1 + locales/languages/ja-jp.json | 1 + locales/languages/ko-kr.json | 1 + locales/languages/pt-br.json | 1 + locales/languages/ru-ru.json | 1 + locales/languages/vi-vn.json | 1 + locales/languages/zh-cn.json | 1 + .../dapp-initiated-transfer.spec.ts | 38 +++++- 18 files changed, 216 insertions(+), 220 deletions(-) diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx index 889d1d627bf..3758215fb12 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { render, waitFor, act } from '@testing-library/react-native'; +import { AppState, AppStateStatus, Linking } from 'react-native'; import AnimatedQRScannerModal from './AnimatedQRScanner'; import { QrScanRequestType } from '@metamask/eth-qr-keyring'; import { URRegistryDecoder } from '@keystonehq/ur-decoder'; @@ -601,25 +602,91 @@ describe('AnimatedQRScannerModal - Metrics', () => { }); describe('Camera Permission Error', () => { - it('calls onScanError only after requestPermission resolves with denial', async () => { + it('re-requests camera permission when app returns to foreground', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; - const mockRequestPermission = jest.fn().mockResolvedValue(false); mockUseCameraPermission.mockReturnValue({ hasPermission: false, requestPermission: mockRequestPermission, }); + let appStateChangeHandler: + | ((nextAppState: AppStateStatus) => void) + | null = null; + const addEventListenerSpy = jest + .spyOn(AppState, 'addEventListener') + .mockImplementation((eventType, listener) => { + if (eventType === 'change') { + appStateChangeHandler = listener; + } + return { remove: jest.fn() }; + }); + render(); + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + expect(addEventListenerSpy).toHaveBeenCalledWith( + 'change', + expect.any(Function), + ); + expect(appStateChangeHandler).not.toBeNull(); + + act(() => { + appStateChangeHandler?.('background'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(1); + }); + + act(() => { + appStateChangeHandler?.('active'); + }); + + await waitFor(() => { + expect(mockRequestPermission).toHaveBeenCalledTimes(2); + }); + + addEventListenerSpy.mockRestore(); + }); + + it('keeps modal open with settings button when permission is denied', async () => { + const mockUseCameraPermission = jest.requireMock( + 'react-native-vision-camera', + ).useCameraPermission; + const openSettingsSpy = jest + .spyOn(Linking, 'openSettings') + .mockResolvedValue(); + + const mockRequestPermission = jest.fn().mockResolvedValue(false); + mockUseCameraPermission.mockReturnValue({ + hasPermission: false, + requestPermission: mockRequestPermission, + }); + + const { getByTestId, getByText } = render( + , + ); + await waitFor(() => { expect(mockRequestPermission).toHaveBeenCalled(); - expect(mockOnScanError).toHaveBeenCalledWith( - 'transaction.no_camera_permission', - ); }); + + expect(mockOnScanError).not.toHaveBeenCalled(); + expect(getByText('transaction.no_camera_permission')).toBeOnTheScreen(); + expect(getByTestId('open-settings-button')).toBeOnTheScreen(); + + await act(async () => { + getByTestId('open-settings-button').props.onPress(); + }); + expect(openSettingsSpy).toHaveBeenCalledTimes(1); + + openSettingsSpy.mockRestore(); }); it('does not call onScanError when requestPermission is granted', async () => { @@ -662,7 +729,7 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - it('does not call onScanError when modal is not visible', async () => { + it('does not request permission when modal is not visible', async () => { const mockUseCameraPermission = jest.requireMock( 'react-native-vision-camera', ).useCameraPermission; @@ -681,7 +748,6 @@ describe('AnimatedQRScannerModal - Metrics', () => { expect(mockOnScanError).not.toHaveBeenCalled(); }); - // requestPermission should not have been called since modal is not visible expect(mockRequestPermission).not.toHaveBeenCalled(); }); }); diff --git a/app/components/UI/QRHardware/AnimatedQRScanner.tsx b/app/components/UI/QRHardware/AnimatedQRScanner.tsx index ecbe2309621..a049512eb1a 100644 --- a/app/components/UI/QRHardware/AnimatedQRScanner.tsx +++ b/app/components/UI/QRHardware/AnimatedQRScanner.tsx @@ -2,8 +2,23 @@ /* eslint @typescript-eslint/no-require-imports: "off" */ 'use strict'; -import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { Image, Text, TouchableOpacity, View, StyleSheet } from 'react-native'; +import React, { + useCallback, + useState, + useEffect, + useMemo, + useRef, +} from 'react'; +import { + AppState, + AppStateStatus, + Image, + Linking, + Text, + TouchableOpacity, + View, + StyleSheet, +} from 'react-native'; import { Camera, useCameraDevice, @@ -110,6 +125,21 @@ const createStyles = (theme: Theme) => flex: 1, justifyContent: 'center', alignItems: 'center', + paddingHorizontal: 24, + }, + openSettingsButton: { + marginTop: 24, + paddingHorizontal: 24, + paddingVertical: 12, + borderRadius: 40, + borderWidth: 1, + borderColor: theme.brandColors.white, + }, + openSettingsText: { + color: theme.brandColors.white, + fontSize: 16, + textAlign: 'center', + ...fontStyles.normal, }, }); @@ -142,6 +172,7 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { const cameraDevice = useCameraDevice('back'); const { hasPermission, requestPermission } = useCameraPermission(); + const appState = useRef(AppState.currentState); let expectedURTypes: string[]; if (purpose === QrScanRequestType.PAIR) { @@ -153,19 +184,42 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { expectedURTypes = [SUPPORTED_UR_TYPE.ETH_SIGNATURE]; } + const refreshCameraPermission = useCallback(() => { + if (!visible || hasPermission) { + return; + } + + requestPermission(); + }, [hasPermission, requestPermission, visible]); + useEffect(() => { - let cancelled = false; - if (!hasPermission && visible) { - requestPermission().then((granted) => { - if (!cancelled && !granted) { - onScanError(strings('transaction.no_camera_permission')); - } - }); + refreshCameraPermission(); + }, [refreshCameraPermission]); + + useEffect(() => { + if (!visible) { + return undefined; } + + const subscription = AppState.addEventListener( + 'change', + (nextAppState: AppStateStatus) => { + const hasReturnedToForeground = + /inactive|background/.test(appState.current) && + nextAppState === 'active'; + + appState.current = nextAppState; + + if (hasReturnedToForeground) { + refreshCameraPermission(); + } + }, + ); + return () => { - cancelled = true; + subscription?.remove?.(); }; - }, [hasPermission, requestPermission, visible, onScanError]); + }, [refreshCameraPermission, visible]); const reset = useCallback(() => { setURDecoder(new URRegistryDecoder()); @@ -336,6 +390,15 @@ const AnimatedQRScannerModal = (props: AnimatedQRScannerProps) => { {strings('transaction.no_camera_permission')} + Linking.openSettings()} + testID="open-settings-button" + > + + {strings('qr_scanner.open_settings')} + +
)} diff --git a/app/components/UI/QRHardware/QRSigningDetails.tsx b/app/components/UI/QRHardware/QRSigningDetails.tsx index b38a92d91f3..894fea8e442 100644 --- a/app/components/UI/QRHardware/QRSigningDetails.tsx +++ b/app/components/UI/QRHardware/QRSigningDetails.tsx @@ -1,16 +1,6 @@ import React, { Fragment, useCallback, useEffect, useState } from 'react'; import Engine from '../../../core/Engine'; -import { - StyleSheet, - Text, - View, - ScrollView, - // eslint-disable-next-line react-native/split-platform-components - PermissionsAndroid, - Linking, - AppState, - AppStateStatus, -} from 'react-native'; +import { StyleSheet, Text, View, ScrollView } from 'react-native'; import { strings } from '../../../../locales/i18n'; import AnimatedQRCode from './AnimatedQRCode'; import AnimatedQRScannerModal from './AnimatedQRScanner'; @@ -25,7 +15,6 @@ import { MetaMetricsEvents } from '../../../core/Analytics'; import { useNavigation } from '@react-navigation/native'; import { useTheme } from '../../../util/theme'; -import Device from '../../../util/device'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { QrScanRequest, QrScanRequestType } from '@metamask/eth-qr-keyring'; @@ -39,7 +28,6 @@ interface IQRSigningDetails { tighten?: boolean; showHint?: boolean; shouldStartAnimated?: boolean; - bypassAndroidCameraAccessCheck?: boolean; fromAddress: string; } @@ -120,7 +108,6 @@ const QRSigningDetails = ({ tighten = false, showHint = true, shouldStartAnimated = true, - bypassAndroidCameraAccessCheck = true, fromAddress, }: IQRSigningDetails) => { const { colors } = useTheme(); @@ -130,50 +117,6 @@ const QRSigningDetails = ({ const [scannerVisible, setScannerVisible] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [shouldPause, setShouldPause] = useState(false); - const [cameraError, setCameraError] = useState(''); - - // ios handled camera perfectly in this situation, we just need to check permission with android. - const [hasCameraPermission, setCameraPermission] = useState( - Device.isIos() || bypassAndroidCameraAccessCheck, - ); - - const checkAndroidCamera = useCallback(() => { - if (Device.isAndroid() && !hasCameraPermission) { - PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( - (_hasPermission) => { - setCameraPermission(_hasPermission); - if (!_hasPermission) { - setCameraError(strings('transaction.no_camera_permission_android')); - } else { - setCameraError(''); - } - }, - ); - } - }, [hasCameraPermission]); - - const handleAppState = useCallback( - (appState: AppStateStatus) => { - if (appState === 'active') { - checkAndroidCamera(); - } - }, - [checkAndroidCamera], - ); - - useEffect(() => { - checkAndroidCamera(); - }, [checkAndroidCamera]); - - useEffect(() => { - const appStateListener = AppState.addEventListener( - 'change', - handleAppState, - ); - return () => { - appStateListener.remove(); - }; - }, [handleAppState]); const [hasSentOrCanceled, setSentOrCanceled] = useState(false); @@ -269,23 +212,12 @@ const QRSigningDetails = ({ ); - const renderCameraAlert = () => - cameraError !== '' && ( - - {cameraError} - - ); - return ( {pendingScanRequest?.request && ( {renderAlert()} - {renderCameraAlert()} diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx index 22ae09f8820..44a1af937e3 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.test.tsx @@ -232,9 +232,6 @@ describe('QRSigningTransactionModal', () => { expect(qrSigningDetailsElement.props.tighten).toBe(true); expect(qrSigningDetailsElement.props.showHint).toBe(true); expect(qrSigningDetailsElement.props.shouldStartAnimated).toBe(true); - expect(qrSigningDetailsElement.props.bypassAndroidCameraAccessCheck).toBe( - false, - ); expect(qrSigningDetailsElement.props.fromAddress).toBe( mockSelectedAccount.address, ); diff --git a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx index 41260f46286..0d9dc6c28ae 100644 --- a/app/components/UI/QRHardware/QRSigningTransactionModal.tsx +++ b/app/components/UI/QRHardware/QRSigningTransactionModal.tsx @@ -110,7 +110,6 @@ const QRSigningTransactionModal = () => { }} cancelCallback={onRejection} failureCallback={onRejection} - bypassAndroidCameraAccessCheck={false} fromAddress={selectedAccount?.address ?? ''} /> )} diff --git a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx index 70e2e01f412..1fdf9d5b125 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx +++ b/app/components/Views/confirmations/context/qr-hardware-context/qr-hardware-context.test.tsx @@ -87,8 +87,8 @@ describe('QRHardwareContext', () => { .mockReturnValue(mockedValues); }; - it('should pass correct value of needsCameraPermission to child components', () => { - createCameraSpy({ cameraError: undefined, hasCameraPermission: false }); + it('does not disable confirm button for camera permission since scanner handles it', () => { + createCameraSpy({ cameraError: undefined, hasCameraPermission: true }); createQRHardwareAwarenessSpy({ isSigningQRObject: true, pendingScanRequest: mockPendingScanRequest, @@ -103,7 +103,7 @@ describe('QRHardwareContext', () => { ); expect( getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON).props.disabled, - ).toBe(true); + ).toBe(false); }); it('does not invoke rejectPendingScan when request is cancelled id QR signing is not in progress', async () => { diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts index 2073286c1bc..8f605c83bc3 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.test.ts @@ -1,32 +1,17 @@ import { renderHook } from '@testing-library/react-native'; -import Device from '../../../../../util/device'; import { useCamera } from './useCamera'; -jest.mock('../../../../../util/device', () => ({ - isIos: () => false, - isAndroid: () => true, -})); - describe('useCamera', () => { - it('returns correct initial values if parameter isSigningQRObject is false', () => { + it('always returns hasCameraPermission as true', () => { const { result } = renderHook(() => useCamera(false)); expect(result.current).toMatchObject({ cameraError: undefined, - hasCameraPermission: false, - }); - }); - - it('returns correct initial values if parameter isSigningQRObject is true', () => { - const { result } = renderHook(() => useCamera(true)); - expect(result.current).toMatchObject({ - cameraError: undefined, - hasCameraPermission: false, + hasCameraPermission: true, }); }); - it('returns correct initial values if device is IOS', () => { - jest.spyOn(Device, 'isIos').mockReturnValue(true); + it('always returns hasCameraPermission as true when signing QR object', () => { const { result } = renderHook(() => useCamera(true)); expect(result.current).toMatchObject({ cameraError: undefined, diff --git a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts index 5f367b3a452..6f33c0b6d8e 100644 --- a/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts +++ b/app/components/Views/confirmations/context/qr-hardware-context/useCamera.ts @@ -1,103 +1,17 @@ -/* eslint-disable react-native/split-platform-components */ -import { useState, useCallback, useEffect } from 'react'; -import { PermissionsAndroid, AppStateStatus, AppState } from 'react-native'; - -import { strings } from '../../../../../../locales/i18n'; -import Device from '../../../../../util/device'; -import { MetaMetricsEvents } from '../../../../../core/Analytics'; -import { useAnalytics } from '../../../../hooks/useAnalytics/useAnalytics'; -import { HardwareDeviceTypes } from '../../../../../constants/keyringTypes'; -import { - PERMISSION_RESULT, - PERMISSION_TYPE, -} from '../../../../../core/Analytics/MetaMetrics.events'; - -export const useCamera = (isSigningQRObject: boolean) => { - const { trackEvent, createEventBuilder } = useAnalytics(); - // todo: integrate with alert system - const [cameraError, setCameraError] = useState(); - - // ios handled camera perfectly in this situation, we just need to check permission with android. - const [hasCameraPermission, setCameraPermission] = useState(Device.isIos()); - - const checkAndroidCamera = useCallback(() => { - if (Device.isAndroid() && !hasCameraPermission) { - PermissionsAndroid.check(PermissionsAndroid.PERMISSIONS.CAMERA).then( - (_hasPermission) => { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: _hasPermission - ? PERMISSION_RESULT.GRANTED - : PERMISSION_RESULT.DENIED, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraPermission(_hasPermission); - if (!_hasPermission) { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: PERMISSION_RESULT.LIMITED, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraError(strings('transaction.no_camera_permission_android')); - } else { - trackEvent( - createEventBuilder( - MetaMetricsEvents.HARDWARE_WALLET_PERMISSION_REQUEST, - ) - .addProperties({ - permission: PERMISSION_TYPE.CAMERA, - result: PERMISSION_RESULT.UNAVAILABLE, - device_type: HardwareDeviceTypes.QR, - }) - .build(), - ); - setCameraError(undefined); - } - }, - ); - } - }, [hasCameraPermission, trackEvent, createEventBuilder]); - - const handleAppState = useCallback( - (appState: AppStateStatus) => { - if (appState === 'active') { - checkAndroidCamera(); - } - }, - [checkAndroidCamera], - ); - - useEffect(() => { - if (!isSigningQRObject) { - return; - } - checkAndroidCamera(); - }, [checkAndroidCamera, isSigningQRObject]); - - useEffect(() => { - if (!isSigningQRObject) { - return; - } - const appStateListener = AppState.addEventListener( - 'change', - handleAppState, - ); - return () => { - appStateListener.remove(); - }; - }, [handleAppState, isSigningQRObject]); - - return { cameraError, hasCameraPermission }; -}; +/** + * Camera permission is fully handled by AnimatedQRScannerModal via + * react-native-vision-camera's useCameraPermission / requestPermission(). + * + * This hook no longer pre-checks or pre-requests Android camera permission + * because PermissionsAndroid and react-native-vision-camera maintain separate + * permission state, and calling PermissionsAndroid.request() can conflict with + * vision-camera's camera initialization pipeline (see #26115). + * + * hasCameraPermission is always true so the "Get signature" button is never + * disabled for permission reasons. The scanner modal will prompt the user + * when it opens. + */ +export const useCamera = (_isSigningQRObject: boolean) => ({ + cameraError: undefined as string | undefined, + hasCameraPermission: true, +}); diff --git a/locales/languages/hi-in.json b/locales/languages/hi-in.json index cb54eff68e0..4a420c7381d 100644 --- a/locales/languages/hi-in.json +++ b/locales/languages/hi-in.json @@ -582,6 +582,7 @@ "ok": "ठीक है", "cancel": "रद्द करें", "error": "त्रुटि", + "open_settings": "सेटिंग", "attempting_to_scan_with_wallet_locked": "ऐसा लगता है कि आप QR कोड स्कैन करने का प्रयास कर रहे हैं, इसका उपयोग करने में सक्षम होने के लिए आपको अपने वॉलेट को अनलॉक करना होगा।", "attempting_sync_from_wallet_error": "लगता है कि आप एक्सटेंशन के साथ सिंक करने का प्रयास कर रहे हैं। ऐसा करने के लिए, आपको अपने वर्तमान वॉलेट को मिटाना होगा। \n\nजब आप ऐप को मिटा देते हैं या उसके नए संस्करण को फिर से इंस्टॉल करते हैं, तो \"MetaMask एक्सटेंशन के साथ सिंक करें\" विकल्प का चयन करें। महत्वपूर्ण! अपना वॉलेट मिटाने से पहले, सुनिश्चित करें कि आपने अपने गुप्त रिकवरी फ्रेज़ का बैकअप ले लिया है।" }, diff --git a/locales/languages/id-id.json b/locales/languages/id-id.json index 244b8266fd9..cc0190af6a9 100644 --- a/locales/languages/id-id.json +++ b/locales/languages/id-id.json @@ -582,6 +582,7 @@ "ok": "Oke", "cancel": "Batal", "error": "Kesalahan", + "open_settings": "Pengaturan", "attempting_to_scan_with_wallet_locked": "Sepertinya Anda mencoba memindai kode QR, Anda perlu membuka dompet agar dapat menggunakannya.", "attempting_sync_from_wallet_error": "Sepertinya Anda mencoba menyinkronkan dengan ekstensi. Untuk melakukannya, Anda akan perlu menghapus dompet Anda saat ini. \n\nSetelah Anda menghapus atau menginstal ulang versi baru aplikasi, pilih opsi untuk \"Menyinkronkan dengan Ekstensi MetaMask\". Penting! Sebelum menghapus dompet, pastikan Anda telah mencadangkan Frasa Pemulihan Rahasia." }, diff --git a/locales/languages/ja-jp.json b/locales/languages/ja-jp.json index af6f4fd0e7e..ab099a12fef 100644 --- a/locales/languages/ja-jp.json +++ b/locales/languages/ja-jp.json @@ -582,6 +582,7 @@ "ok": "OK", "cancel": "キャンセル", "error": "エラー", + "open_settings": "設定", "attempting_to_scan_with_wallet_locked": "QRコードを読み取ろうとしていますが、ウォレットのロックを解除する必要があります。", "attempting_sync_from_wallet_error": "拡張機能と同期しようとしています。実行するには、現在のウォレットを消去する必要があります。\n\n新しいバージョンのアプリを消去または再インストールした後、[Sync with MetaMask Extension]というオプションを選択してください。重要!ウォレットを消去する前に、シークレットリカバリーフレーズのバックアップを取っておくことをお勧めします。" }, diff --git a/locales/languages/ko-kr.json b/locales/languages/ko-kr.json index 2313fe9662d..1ee186bc10b 100644 --- a/locales/languages/ko-kr.json +++ b/locales/languages/ko-kr.json @@ -582,6 +582,7 @@ "ok": "확인", "cancel": "취소", "error": "오류", + "open_settings": "설정", "attempting_to_scan_with_wallet_locked": "QR 코드 스캔을 시도 중이십니까? 이를 사용하려면 지갑을 잠금 해제해야 합니다.", "attempting_sync_from_wallet_error": "확장 프로그램과 동기화를 시도 중인 것 같습니다. 이를 위해서는 기존 지갑을 지워야 합니다. \n\n지우기를 완료한 후 또는 최신 버전의 앱을 재설치한 후 \"MetaMask 확장 프로그램과 동기화\" 옵션을 선택하십시오. 중요! 지갑을 지우기 전에 계정 시드 구문을 백업했는지 확인하십시오." }, diff --git a/locales/languages/pt-br.json b/locales/languages/pt-br.json index 7ad0976e19b..3922b24b723 100644 --- a/locales/languages/pt-br.json +++ b/locales/languages/pt-br.json @@ -582,6 +582,7 @@ "ok": "OK", "cancel": "Cancelar", "error": "Erro", + "open_settings": "Configurações", "attempting_to_scan_with_wallet_locked": "Parece que você está tentando escanear um código QR. Você deve destravar sua carteira para conseguir usá-la.", "attempting_sync_from_wallet_error": "Parece que você está tentando sincronizar com uma extensão. Para fazê-lo, você precisará excluir sua carteira atual. \n\nApós ter excluído ou reinstalado uma versão mais recente do app, selecione a opção para \"Sincronizar com a Extensão MetaMask\". Importante! Antes de excluir a sua carteira, certifique-se de ter feito uma cópia de segurança da sua Frase de Recuperação Secreta." }, diff --git a/locales/languages/ru-ru.json b/locales/languages/ru-ru.json index f064b3e261d..3ba2d8bacef 100644 --- a/locales/languages/ru-ru.json +++ b/locales/languages/ru-ru.json @@ -582,6 +582,7 @@ "ok": "ОК", "cancel": "Отмена", "error": "Ошибка", + "open_settings": "Настройки", "attempting_to_scan_with_wallet_locked": "Похоже, вы пытаетесь отсканировать QR-код. Чтобы использовать его, нужно разблокировать кошелек.", "attempting_sync_from_wallet_error": "Похоже, вы пытаетесь выполнить синхронизацию с расширением. Сначала нужно удалить текущий кошелек. \n\nКогда вы удалите кошелек или установите новую версию приложения, выберите параметр «Синхронизировать с расширением MetaMask». Важно! Прежде чем удалять кошелек, создайте резервную копию секретной фразы восстановления." }, diff --git a/locales/languages/vi-vn.json b/locales/languages/vi-vn.json index 48b55622c1b..25830b2b098 100644 --- a/locales/languages/vi-vn.json +++ b/locales/languages/vi-vn.json @@ -582,6 +582,7 @@ "ok": "Ok", "cancel": "Hủy", "error": "Lỗi", + "open_settings": "Cài đặt", "attempting_to_scan_with_wallet_locked": "Có vẻ như bạn đang cố gắng quét một mã QR. Bạn cần mở khóa ví của mình trước thì mới có thể sử dụng mã đó.", "attempting_sync_from_wallet_error": "Có vẻ như bạn đang cố gắng đồng bộ hóa với tiện ích. Để có thể làm như vậy, bạn sẽ cần xóa ví hiện tại của mình. \n\nSau khi bạn xóa hoặc cài đặt lại một phiên bản mới của ứng dụng, hãy chọn tùy chọn để \"Đồng bộ hóa với tiện ích MetaMask\". Quan trọng! Trước khi xóa ví, hãy đảm bảo bạn đã sao lưu Cụm mật khẩu khôi phục bí mật." }, diff --git a/locales/languages/zh-cn.json b/locales/languages/zh-cn.json index 01d0f9177bc..a14182f276b 100644 --- a/locales/languages/zh-cn.json +++ b/locales/languages/zh-cn.json @@ -562,6 +562,7 @@ "ok": "确定", "cancel": "取消", "error": "错误", + "open_settings": "设置", "attempting_sync_from_wallet_error": "好像您正尝试与扩展程序同步。要进行同步,请转至“设置”>“高级”>“与 MetaMask 扩展程序同步”" }, "action_view": { diff --git a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts index 3c29e8929ae..98ab85f07b1 100644 --- a/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts +++ b/tests/smoke/confirmations/transactions/dapp-initiated-transfer.spec.ts @@ -147,13 +147,24 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { await navigateToBrowserView(); await Browser.navigateToTestDApp(); + await Assertions.expectElementToBeVisible(TestDApp.testDappPageTitle, { + description: 'Test dapp page title should be visible', + }); + await Assertions.expectElementToBeVisible(TestDApp.sendEIP1559Button, { + description: 'Send EIP1559 button should be visible', + }); await TestDApp.tapSendEIP1559Button(); // Check all expected elements are visible await Assertions.expectElementToBeVisible( ConfirmationUITypes.ModalConfirmationContainer, + { + description: 'Transaction confirmation modal should be visible', + }, ); - await Assertions.expectElementToBeVisible(RowComponents.TokenHero); + await Assertions.expectElementToBeVisible(RowComponents.TokenHero, { + description: 'Token hero row should be visible', + }); await Assertions.expectTextDisplayed('0 ETH'); await Assertions.expectElementToBeVisible(RowComponents.FromTo); await Assertions.expectElementToBeVisible( @@ -169,11 +180,34 @@ describe(SmokeConfirmations('DApp Initiated Transfer'), () => { // Scroll to Advanced Details section on Android if (device.getPlatform() === 'android') { - await Gestures.swipe(RowComponents.GasFeesDetails, 'up'); + await Gestures.swipe( + ConfirmationUITypes.ModalConfirmationContainer, + 'up', + { + elemDescription: 'Scroll transaction confirmation content', + }, + ); } + await Assertions.expectElementToBeVisible( + RowComponents.SimulationDetails, + { + description: 'Simulation details row should be visible', + timeout: 30000, + }, + ); + await Assertions.expectElementToBeVisible( + RowComponents.GasFeesDetails, + { + description: 'Gas fees details row should be visible', + timeout: 30000, + }, + ); await Assertions.expectElementToBeVisible( RowComponents.AdvancedDetails, + { + description: 'Advanced details row should be visible', + }, ); // Accept confirmation From aefcd11c4a66b7a33a6976b659871e183132a610 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ant=C3=B3nio=20Regadas?= Date: Wed, 4 Mar 2026 11:18:01 +0100 Subject: [PATCH 08/10] fix: market insights disclaimer text update (#26971) ## **Description** Small text update to the entry card disclaimer. ## **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] > **Low Risk** > Low risk text-only change: updates i18n keys used for the Market Insights disclaimers and removes unused English strings, with no logic or data-flow changes. > > **Overview** > Updates Market Insights UI to use a single `market_insights.footer_disclaimer` string for both the entry card and full view footer, replacing the previous `disclaimer`/`fixed_footer_disclaimer` keys. > > Cleans up `en.json` by removing the old keys and keeping the updated disclaimer copy in one place. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit f17ed96ee97093800f93e8d2054ec661ef6e4355. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/MarketInsightsView/MarketInsightsView.tsx | 2 +- .../MarketInsightsEntryCard/MarketInsightsEntryCard.tsx | 2 +- locales/languages/en.json | 3 +-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx index 4f3ae7a04d9..b867b137520 100644 --- a/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx +++ b/app/components/UI/MarketInsights/Views/MarketInsightsView/MarketInsightsView.tsx @@ -519,7 +519,7 @@ const MarketInsightsView: React.FC = () => { variant={TextVariant.BodySm} color={TextColor.TextAlternative} > - {strings('market_insights.fixed_footer_disclaimer')} + {strings('market_insights.footer_disclaimer')} diff --git a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx index 459531d0214..c2147dec37d 100644 --- a/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx +++ b/app/components/UI/MarketInsights/components/MarketInsightsEntryCard/MarketInsightsEntryCard.tsx @@ -127,7 +127,7 @@ const MarketInsightsEntryCard: React.FC = ({ variant={TextVariant.BodySm} color={TextColor.TextAlternative} > - {strings('market_insights.disclaimer')} + {strings('market_insights.footer_disclaimer')} Date: Wed, 4 Mar 2026 17:37:29 +0700 Subject: [PATCH 09/10] fix(analytics): correct source prop for Perps section ">" navigation event (#26785) ## **Description** When tapping the `>` arrow on the Perps homepage section, the `Perp Screen Viewed` analytics event was firing with `source: main_action_button` (the fallback default) instead of the correct entry point. This fix passes `source: home_screen` explicitly when navigating from the homepage section title and "View more" card. Changes: - Added `HOME_SCREEN: 'home_screen'` to `PERPS_EVENT_VALUE.SOURCE` constants - Updated `handleViewAllPerps` in `PerpsSection` to pass the new source param on navigation - Updated related tests to assert the correct source value ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-501 ## **Manual testing steps** ```gherkin Feature: Perps homepage section navigation analytics Scenario: user taps the > arrow on the Perps section Given the user is on the homepage with the Perps section visible When user taps the ">" arrow next to "Perpetuals" Then the "Perp Screen Viewed" event fires with source = "home_screen" Scenario: user taps "View more" in the trending carousel Given the user is on the homepage with no open positions or orders When user taps the "View more" card in the Perps trending carousel Then the "Perp Screen Viewed" event fires with source = "home_screen" ``` ## **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 - [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** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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] > **Low Risk** > Only updates test case descriptions (no runtime logic changes), so behavior and production risk are minimal. > > **Overview** > Updates `PerpsSection.test.tsx` to clarify in test names which `source` param is expected when navigating to Perps home from the homepage section title and the "View more" card. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit fcf949592dcbce283dbc037e571464284ab9a5f7. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx index cd041afcae1..5b425fb8303 100644 --- a/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx +++ b/app/components/Views/Homepage/Sections/Perpetuals/PerpsSection.test.tsx @@ -353,7 +353,7 @@ describe('PerpsSection', () => { expect(roeElements.length).toBeGreaterThanOrEqual(2); }); - it('navigates to perps home on title press', () => { + it('navigates to perps home on title press with home_section source', () => { renderWithProvider(); fireEvent.press(screen.getByText('Perpetuals')); @@ -715,7 +715,7 @@ describe('PerpsSection', () => { expect(screen.getByText('View more')).toBeOnTheScreen(); }); - it('navigates to perps home when "View more" card is pressed', () => { + it('navigates to perps home with home_screen source when "View more" card is pressed', () => { usePerpsMarkets.mockReturnValue({ markets: [ makeTrendingMarket({ symbol: 'BTC', volumeNumber: 5000000000 }), From 2ec96ff6a0c52854164e748dd7c2a499c995756d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luis=20Tani=C3=A7a?= Date: Wed, 4 Mar 2026 04:08:08 -0700 Subject: [PATCH 10/10] fix(predict): refresh balance/allowance before Polymarket order submission (#26954) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** Polymarket's CLOB infrastructure is intermittently returning 400 errors with "not enough balance / allowance" when placing orders at high request rates. As a temporary workaround communicated by the Polymarket team, we now call `GET /balance-allowance/update` before each order to refresh the balance/allowance state on their end. ### Changes: - **`utils.ts`**: Add `refreshBalanceAllowance()` utility that calls the CLOB balance-allowance/update endpoint with L2 HMAC auth headers - BUY orders → `asset_type=COLLATERAL` (USDC) - SELL orders → `asset_type=CONDITIONAL` with the order's `token_id` - **`PolymarketProvider.ts`**: Call `refreshBalanceAllowance()` in `placeOrder()` right before `submitClobOrder()`, wrapped in try/catch so failures don't block order submission - **`utils.test.ts`**: 5 new tests covering BUY/SELL paths, auth headers, error resilience, and custom signature types - **`PolymarketProvider.test.ts`**: 4 new integration tests verifying call ordering, parameter passing for both sides, and graceful degradation on refresh failure > **Note**: This is a temporary workaround. TODO comments are in place for removal once Polymarket resolves the underlying infrastructure issue. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-726 ## **Manual testing steps** ```gherkin Feature: Polymarket order placement with balance/allowance refresh Scenario: user places a BUY order on a prediction market Given the user has USDC deposited in their Polymarket proxy wallet When user places a BUY order on any market Then the balance/allowance refresh call fires before the order submission And the order completes successfully Scenario: user places a SELL order on a prediction market Given the user holds outcome tokens for a market When user places a SELL order Then the balance/allowance refresh call fires with the token_id before the order And the order completes successfully Scenario: balance/allowance refresh endpoint is down Given the refresh endpoint returns an error When user places any order Then the order submission still proceeds normally ``` ## **Screenshots/Recordings** N/A — 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. --- > [!NOTE] > **Medium Risk** > Adds a new preflight network call in the order placement path; while failures are best-effort and don’t block orders, it changes timing/behavior in a core trading flow and could affect reliability or rate limits. > > **Overview** > Adds a temporary workaround for intermittent Polymarket CLOB `not enough balance / allowance` errors by introducing `refreshBalanceAllowance()` (calls `GET /balance-allowance/update` with L2 HMAC headers) and invoking it in `PolymarketProvider.placeOrder()` immediately before `submitClobOrder()`. > > The refresh is **best-effort** (wrapped in `try/catch` with logging) and varies request params by side: **BUY** refreshes `asset_type=COLLATERAL`, **SELL** refreshes `asset_type=CONDITIONAL` with `token_id`. Updates unit/integration tests to cover parameter selection, call ordering, header usage, and non-blocking behavior on refresh failure. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8a46817d41e1bbe86c48f7ad38c47e6e8e9a340e. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../polymarket/PolymarketProvider.test.ts | 94 ++++++++++++++++++ .../polymarket/PolymarketProvider.ts | 18 ++++ .../providers/polymarket/utils.test.ts | 95 +++++++++++++++++++ .../UI/Predict/providers/polymarket/utils.ts | 71 ++++++++++++++ 4 files changed, 278 insertions(+) diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 9edb8fa2c44..76437ee1328 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -69,6 +69,7 @@ import { parsePolymarketPositions, previewOrder, priceValid, + refreshBalanceAllowance, submitClobOrder, } from './utils'; @@ -109,6 +110,7 @@ jest.mock('./utils', () => { priceValid: jest.fn(), createApiKey: jest.fn(), submitClobOrder: jest.fn(), + refreshBalanceAllowance: jest.fn(), getMarketPositions: jest.fn(), getBalance: jest.fn(), previewOrder: jest.fn(), @@ -212,6 +214,7 @@ const mockParsePolymarketPositions = parsePolymarketPositions as jest.Mock; const mockPriceValid = priceValid as jest.Mock; const mockCreateApiKey = createApiKey as jest.Mock; const mockSubmitClobOrder = submitClobOrder as jest.Mock; +const mockRefreshBalanceAllowance = refreshBalanceAllowance as jest.Mock; const mockEncodeClaim = encodeClaim as jest.Mock; const mockComputeProxyAddress = computeProxyAddress as jest.Mock; const mockCreatePermit2FeeAuthorization = @@ -1603,6 +1606,97 @@ describe('PolymarketProvider', () => { }); }); + describe('placeOrder balance/allowance refresh workaround', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockRefreshBalanceAllowance.mockResolvedValue(undefined); + }); + + it('calls refreshBalanceAllowance with COLLATERAL before submitting a BUY order', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ + address: mockSigner.address, + apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), + side: Side.BUY, + outcomeTokenId: preview.outcomeTokenId, + }); + }); + + it('calls refreshBalanceAllowance with CONDITIONAL before submitting a SELL order', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const preview = createMockOrderPreview({ side: Side.SELL }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(mockRefreshBalanceAllowance).toHaveBeenCalledWith({ + address: mockSigner.address, + apiKey: expect.objectContaining({ apiKey: 'test-api-key' }), + side: Side.SELL, + outcomeTokenId: preview.outcomeTokenId, + }); + }); + + it('calls refreshBalanceAllowance before submitClobOrder', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + const callOrder: string[] = []; + mockRefreshBalanceAllowance.mockImplementation(async () => { + callOrder.push('refresh'); + }); + mockSubmitClobOrder.mockImplementation(async () => { + callOrder.push('submit'); + return { + success: true, + response: { + success: true, + makingAmount: '1000000', + orderID: 'order-123', + status: 'success', + takingAmount: '0', + transactionsHashes: [], + }, + error: undefined, + }; + }); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + await provider.placeOrder({ signer: mockSigner, preview }); + + // Assert + expect(callOrder).toEqual(['refresh', 'submit']); + }); + + it('proceeds with order submission when refreshBalanceAllowance fails', async () => { + // Arrange + const { provider, mockSigner } = setupPlaceOrderTest(); + mockRefreshBalanceAllowance.mockRejectedValue( + new Error('Network timeout'), + ); + const preview = createMockOrderPreview({ side: Side.BUY }); + + // Act + const result = await provider.placeOrder({ + signer: mockSigner, + preview, + }); + + // Assert - order still submitted despite refresh failure + expect(mockSubmitClobOrder).toHaveBeenCalled(); + expect(result.success).toBe(true); + }); + }); + describe('placeOrder with Safe fee authorization', () => { it('computes Safe address before creating order', async () => { const { provider, mockSigner } = setupPlaceOrderTest(); diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index 3a88fbb1a8f..fb1b02de3d3 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -102,6 +102,7 @@ import { parsePolymarketEvents, parsePolymarketPositions, previewOrder, + refreshBalanceAllowance, roundOrderAmount, submitClobOrder, } from './utils'; @@ -1306,6 +1307,23 @@ export class PolymarketProvider implements PredictProvider { apiKey: signerApiKey, }); + // TEMPORARY WORKAROUND: Refresh balance/allowance on Polymarket's CLOB + // before submitting the order. See refreshBalanceAllowance docs for details. + try { + await refreshBalanceAllowance({ + address: signer.address, + apiKey: signerApiKey, + side, + outcomeTokenId, + }); + } catch (refreshError) { + // Best-effort — don't block order submission if the refresh fails + DevLogger.log( + 'PolymarketProvider: Pre-order balance/allowance refresh failed', + refreshError, + ); + } + const { success, response, error } = await submitClobOrder({ headers, clobOrder, diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index cc79f15b55c..caf72518f44 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -60,6 +60,7 @@ import { parsePolymarketPositions, parsePolymarketActivity, priceValid, + refreshBalanceAllowance, submitClobOrder, decimalPlaces, roundNormal, @@ -701,6 +702,100 @@ describe('polymarket utils', () => { }); }); + describe('refreshBalanceAllowance', () => { + beforeEach(() => { + mockFetch.mockReset(); + (global.crypto as any).createHmac.mockReturnValue({ + update: jest.fn().mockReturnThis(), + digest: jest.fn().mockReturnValue('mock-digest-base64'), + }); + }); + + it('sends COLLATERAL asset_type for BUY orders', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('/balance-allowance/update?'), + expect.objectContaining({ method: 'GET' }), + ); + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('asset_type=COLLATERAL'); + expect(calledUrl).toContain('signature_type=2'); + expect(calledUrl).not.toContain('token_id='); + }); + + it('sends CONDITIONAL asset_type with token_id for SELL orders', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.SELL, + outcomeTokenId: 'token-456', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('asset_type=CONDITIONAL'); + expect(calledUrl).toContain('token_id=token-456'); + expect(calledUrl).toContain('signature_type=2'); + }); + + it('calls CLOB_ENDPOINT with L2 auth headers', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toMatch(/^https:\/\/clob\.polymarket\.com/); + const calledOptions = mockFetch.mock.calls[0][1] as RequestInit; + expect(calledOptions.headers).toEqual( + expect.objectContaining({ + POLY_ADDRESS: mockAddress, + }), + ); + }); + + it('does not throw when response is not ok', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + await expect( + refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + }), + ).resolves.toBeUndefined(); + }); + + it('uses custom signatureType when provided', async () => { + mockFetch.mockResolvedValue({ ok: true }); + + await refreshBalanceAllowance({ + address: mockAddress, + apiKey: mockApiKey, + side: Side.BUY, + outcomeTokenId: 'token-123', + signatureType: SignatureType.EOA, + }); + + const calledUrl = mockFetch.mock.calls[0][0] as string; + expect(calledUrl).toContain('signature_type=0'); + }); + }); + describe('submitClobOrder', () => { const mockHeaders: ClobHeaders = { POLY_ADDRESS: mockAddress, diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index 2d95c877648..1c6dcc71c87 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -61,6 +61,7 @@ import { PolymarketApiMarket, PolymarketApiTeam, PolymarketPosition, + SignatureType, TickSize, OrderBook, } from './types'; @@ -187,6 +188,76 @@ export const getL2Headers = async ({ return headers; }; +/** + * TEMPORARY WORKAROUND for Polymarket infrastructure issue. + * + * Polymarket's CLOB infrastructure intermittently returns 400 errors with + * "not enough balance / allowance" when placing orders at high request rates. + * Calling this endpoint before each order refreshes the balance/allowance state + * on their end and prevents most of these spurious failures. + * + * For BUY orders: refreshes COLLATERAL (USDC) balance/allowance. + * For SELL orders: refreshes CONDITIONAL token balance/allowance. + * + * TODO: Remove this workaround once Polymarket resolves the underlying + * infrastructure issue. Track removal in a follow-up ticket. + */ +export const refreshBalanceAllowance = async ({ + address, + apiKey, + side, + outcomeTokenId, + signatureType = SignatureType.POLY_GNOSIS_SAFE, +}: { + address: string; + apiKey: ApiKeyCreds; + side: Side; + outcomeTokenId: string; + signatureType?: SignatureType; +}): Promise => { + const { CLOB_ENDPOINT } = getPolymarketEndpoints(); + + const queryParams = new URLSearchParams({ + signature_type: String(signatureType), + }); + + if (side === Side.BUY) { + queryParams.set('asset_type', 'COLLATERAL'); + } else { + queryParams.set('asset_type', 'CONDITIONAL'); + queryParams.set('token_id', outcomeTokenId); + } + + const requestPath = `/balance-allowance/update`; + + const headers = await getL2Headers({ + l2HeaderArgs: { + method: 'GET', + requestPath, + }, + address, + apiKey, + }); + + const response = await fetch( + `${CLOB_ENDPOINT}${requestPath}?${queryParams.toString()}`, + { + method: 'GET', + headers, + }, + ); + + if (!response.ok) { + DevLogger.log( + 'refreshBalanceAllowance: Pre-order balance/allowance refresh failed', + { + status: response.status, + side, + }, + ); + } +}; + export const deriveApiKey = async ({ address }: { address: string }) => { const { CLOB_ENDPOINT } = getPolymarketEndpoints(); const headers = await getL1Headers({ address });