diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 169e5ccc3dc..9576fde1dd0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -47,7 +47,7 @@ app/util/walletconnect.js @MetaMask/sdk-devs # Accounts Team app/core/Encryptor/ @MetaMask/accounts-engineers -app/core/Engine/controllers/accounts @MetaMask/accounts-engineers +app/core/Engine/controllers/AccountsController @MetaMask/accounts-engineers # Swaps Team app/components/UI/Swaps @MetaMask/swaps-engineers diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml index 31ee91b2191..4214943e066 100644 --- a/.github/ISSUE_TEMPLATE/bug-report.yml +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -57,7 +57,8 @@ body: - In production (default) - In beta - During release testing - - On the development branch + - On main branch + - On a feature branch validations: required: true - type: input diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index ee49fad0639..4b36fc4275e 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -25,7 +25,8 @@ To merge your PR one of the following QA labels are required: - **Run E2E Smoke**: This label will kick-off E2E testing and trigger a check to make sure the E2E tests pass. ### Optional labels: -- **regression-develop**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on the development branch, i.e., `main`, but is not yet released in production. +- **regression-main**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on the development branch, i.e., `main`, but is not yet released in production. +- **feature-branch-bug**: This label can manually be added to a bug report issue at the time of its creation if the bug is present on a feature branch, i.e., before merging to `main`. ### Labels prohibited when PR needs to be merged: Any PR that includes one of the following labels can not be merged: diff --git a/.github/scripts/bitrise/run-bitrise-e2e-check.ts b/.github/scripts/bitrise/run-bitrise-e2e-check.ts index 62935ca966d..123920a036d 100644 --- a/.github/scripts/bitrise/run-bitrise-e2e-check.ts +++ b/.github/scripts/bitrise/run-bitrise-e2e-check.ts @@ -8,11 +8,107 @@ import { } from '../scripts.types'; import axios from 'axios'; +let octokitInstance: InstanceType | null = null; +let owner: string; +let repo: string; + main().catch((error: Error): void => { console.error(error); process.exit(1); }); + + +function getOctokitInstance(): InstanceType { + if (!octokitInstance) { + const githubToken = process.env.GITHUB_TOKEN; + if (!githubToken) { + throw new Error("GitHub token is not set in the environment variables"); + } + octokitInstance = getOctokit(githubToken); + } + return octokitInstance; +} + +async function upsertStatusCheck( + statusCheckName: string, + commitHash: string, + status: StatusCheckStatusType, + conclusion: CompletedConclusionType | undefined, + summary: string +): Promise { + const octokit = getOctokitInstance(); + + // List existing checks + const listResponse = await octokit.rest.checks.listForRef({ + owner, + repo, + ref: commitHash, + }); + + if (listResponse.status !== 200) { + core.setFailed( + `Failed to list checks for commit ${commitHash}, received status code ${listResponse.status}`, + ); + process.exit(1); + } + + const existingCheck = listResponse.data.check_runs.find(check => check.name === statusCheckName); + + if (existingCheck) { + console.log(`Check already exists: ${existingCheck.name}, updating...`); + // Update the existing check + const updateCheckResponse = await octokit.rest.checks.update({ + owner, + repo, + check_run_id: existingCheck.id, + name: statusCheckName, + status: status, + conclusion: conclusion, + output: { + title: `${statusCheckName} Status Check`, + summary: summary, + }, + }); + + if (updateCheckResponse.status !== 200) { + core.setFailed( + `Failed to update '${statusCheckName}' check with status ${status} for commit ${commitHash}, got status code ${updateCheckResponse.status}`, + ); + process.exit(1); + } + + console.log(`Updated existing check: ${statusCheckName} with id ${existingCheck.id} & status ${status} for commit ${commitHash}`); + + + + } else { + console.log(`Check does not exist: ${statusCheckName}, creating...`); + // Create a new status check + const createCheckResponse = await octokit.rest.checks.create({ + owner, + repo, + name: statusCheckName, + head_sha: commitHash, + status: status, + conclusion: conclusion, + started_at: new Date().toISOString(), + output: { + title: `${statusCheckName} Status Check`, + summary: summary, + }, + }); + + if (createCheckResponse.status !== 201) { + core.setFailed( + `Failed to create '${statusCheckName}' check with status ${status} for commit ${commitHash}, got status code ${createCheckResponse.status}`, + ); + process.exit(1); + } + + console.log(`Created check: ${statusCheckName} with id ${createCheckResponse.data.id} & status ${status} for commit ${commitHash}`); + } +} // Determine whether E2E should run and provide the associated reason function shouldRunBitriseE2E(antiLabel: boolean, hasSmokeTestLabel: boolean, isDocs: boolean, isFork: boolean, isMergeQueue: boolean): [boolean, string] { @@ -43,7 +139,11 @@ async function main(): Promise { const e2ePipeline = process.env.E2E_PIPELINE; const workflowName = process.env.WORKFLOW_NAME; const triggerAction = context.payload.action as PullRequestTriggerType; - const { owner, repo, number: pullRequestNumber } = context.issue; + // Assuming context.issue comes populated with owner and repo, as typical with GitHub Actions + const { owner: contextOwner, repo: contextRepo, number: pullRequestNumber } = context.issue; + owner = contextOwner; + repo = contextRepo; + const removeAndApplyInstructions = `Remove and re-apply the "${e2eLabel}" label to trigger a E2E smoke test on Bitrise.`; const mergeFromMainCommitMessagePrefix = `Merge branch 'main' into`; const pullRequestLink = `https://github.com/MetaMask/metamask-mobile/pull/${pullRequestNumber}`; @@ -80,7 +180,7 @@ async function main(): Promise { const mqCommitHash = context.payload?.merge_group?.head_sha; - const octokit: InstanceType = getOctokit(githubToken); + const octokit = getOctokitInstance(); const { data: prData } = await octokit.rest.pulls.get({ owner, @@ -114,34 +214,9 @@ async function main(): Promise { if (!mergeQueue && !hasSmokeTestLabel && !hasAntiLabel) { // Fail Status due to missing labels - const createStatusCheckResponse = await octokit.rest.checks.create({ - owner, - repo, - name: statusCheckName, - head_sha: latestCommitHash, - status: StatusCheckStatusType.Completed, - conclusion: CompletedConclusionType.Failure, - started_at: new Date().toISOString(), - output: { - title: statusCheckTitle, - summary: `Failed due to missing labels. Please apply either ${e2eLabel} or ${antiLabel}.`, - }, - }); - - if (createStatusCheckResponse.status === 201) { - console.log( - `Created '${statusCheckName}' check with failed status for commit ${latestCommitHash}`, - ); - } else { - core.setFailed( - `Failed to create '${statusCheckName}' check with failed status for commit ${latestCommitHash} with status code ${createStatusCheckResponse.status}`, - ); - process.exit(1); - } - core.setFailed( - `At least 1 E2E Label must be Applied either ${e2eLabel} or ${antiLabel}`, - ); - process.exit(1); + await upsertStatusCheck(statusCheckName, latestCommitHash, StatusCheckStatusType.Completed, + CompletedConclusionType.Failure, `Failed due to missing labels. Please apply either ${e2eLabel} or ${antiLabel}.`); + return } if (!shouldRun) { @@ -149,32 +224,8 @@ async function main(): Promise { `Skipping Bitrise status check. due to the following reason: ${reason}`, ); - - // Post success status (skipped) - const createStatusCheckResponse = await octokit.rest.checks.create({ - owner, - repo, - name: statusCheckName, - head_sha: latestCommitHash, - status: StatusCheckStatusType.Completed, - conclusion: CompletedConclusionType.Success, - started_at: new Date().toISOString(), - output: { - title: statusCheckTitle, - summary: `Skip run since ${reason}`, - }, - }); - - if (createStatusCheckResponse.status === 201) { - console.log( - `Created '${statusCheckName}' check with skipped status for commit ${latestCommitHash}`, - ); - } else { - core.setFailed( - `Failed to create '${statusCheckName}' check with skipped status for commit ${latestCommitHash} with status code ${createStatusCheckResponse.status}`, - ); - process.exit(1); - } + await upsertStatusCheck(statusCheckName, latestCommitHash, StatusCheckStatusType.Completed, CompletedConclusionType.Success, + `Skip run since ${reason}`); return; } @@ -314,29 +365,9 @@ async function main(): Promise { // Post pending status console.log(`Posting pending status for commit ${latestCommitHash}`); - const createStatusCheckResponse = await octokit.rest.checks.create({ - owner, - repo, - name: statusCheckName, - head_sha: latestCommitHash, - status: StatusCheckStatusType.InProgress, - started_at: new Date().toISOString(), - output: { - title: statusCheckTitle, - summary: `Test runs in progress... You can view them at ${buildLink}`, - }, - }); + await upsertStatusCheck( statusCheckName, latestCommitHash, StatusCheckStatusType.InProgress, undefined, `Test runs in progress... You can view them at ${buildLink}`); + - if (createStatusCheckResponse.status === 201) { - console.log( - `Created '${statusCheckName}' check for commit ${latestCommitHash}`, - ); - } else { - core.setFailed( - `Failed to create '${statusCheckName}' check for commit ${latestCommitHash} with status code ${createStatusCheckResponse.status}`, - ); - process.exit(1); - } return; } @@ -383,31 +414,11 @@ async function main(): Promise { if (!bitriseComment) { console.log(`Bitrise comment not detected for commit ${latestCommitHash}`); - // Post fail status - const createStatusCheckResponse = await octokit.rest.checks.create({ - owner, - repo, - name: statusCheckName, - head_sha: latestCommitHash, - status: StatusCheckStatusType.Completed, - conclusion: CompletedConclusionType.Failure, - started_at: new Date().toISOString(), - output: { - title: statusCheckTitle, - summary: `No Bitrise comment found for commit ${latestCommitHash}. Try re-applying the '${e2eLabel}' label.`, - }, - }); - if (createStatusCheckResponse.status === 201) { - console.log( - `Created '${statusCheckName}' check for commit ${latestCommitHash}`, - ); - } else { - core.setFailed( - `Failed to create '${statusCheckName}' check for commit ${latestCommitHash} with status code ${createStatusCheckResponse.status}`, - ); - process.exit(1); - } + await upsertStatusCheck(statusCheckName, latestCommitHash, StatusCheckStatusType.Completed, + CompletedConclusionType.Failure, + `No Bitrise comment found for commit ${latestCommitHash}. Try re-applying the '${e2eLabel}' label.`); + return; } @@ -498,27 +509,6 @@ async function main(): Promise { } // Post status check - const createStatusCheckResponse = await octokit.rest.checks.create({ - owner, - repo, - name: statusCheckName, - head_sha: latestCommitHash, - started_at: new Date().toISOString(), - output: { - title: statusCheckTitle, - summary: statusMessage, - }, - ...checkStatus, - }); + await upsertStatusCheck(statusCheckName, latestCommitHash, checkStatus.status, checkStatus.conclusion, statusMessage); - if (createStatusCheckResponse.status === 201) { - console.log( - `Created '${statusCheckName}' check for commit ${latestCommitHash}`, - ); - } else { - core.setFailed( - `Failed to create '${statusCheckName}' check for commit ${latestCommitHash} with status code ${createStatusCheckResponse.status}`, - ); - process.exit(1); - } } diff --git a/.github/scripts/check-template-and-add-labels.ts b/.github/scripts/check-template-and-add-labels.ts index e0a59e21d8e..fef8a5585d1 100644 --- a/.github/scripts/check-template-and-add-labels.ts +++ b/.github/scripts/check-template-and-add-labels.ts @@ -20,7 +20,8 @@ import { TemplateType, templates } from './shared/template'; import { retrievePullRequest } from './shared/pull-request'; enum RegressionStage { - Development, + DevelopmentFeature, + DevelopmentMain, Testing, Beta, Production @@ -202,8 +203,10 @@ function extractRegressionStageFromBugReportIssueBody( const extractedAnswer = match ? match[1].trim() : undefined; switch (extractedAnswer) { - case 'On the development branch': - return RegressionStage.Development; + case 'On a feature branch': + return RegressionStage.DevelopmentFeature; + case 'On main branch': + return RegressionStage.DevelopmentMain; case 'During release testing': return RegressionStage.Testing; case 'In beta': @@ -317,11 +320,18 @@ async function userBelongsToMetaMaskOrg( // This function crafts appropriate label, corresponding to regression stage and release version. function craftRegressionLabel(regressionStage: RegressionStage | undefined, releaseVersion: string | undefined): Label { switch (regressionStage) { - case RegressionStage.Development: + case RegressionStage.DevelopmentFeature: + return { + name: `feature-branch-bug`, + color: '5319E7', // violet + description: `bug that was found on a feature branch, but not yet merged in main branch`, + }; + + case RegressionStage.DevelopmentMain: return { name: `regression-develop`, color: '5319E7', // violet - description: `Regression bug that was found on development branch, but not yet present in production`, + description: `Regression bug that was found on main branch, but not yet present in production`, }; case RegressionStage.Testing: diff --git a/.js.env.example b/.js.env.example index 1c11f591536..56c7c1bb865 100644 --- a/.js.env.example +++ b/.js.env.example @@ -1,16 +1,15 @@ # Sign up and generate your own keys at pubnub.com # Then rename this file to ".js.env" and rebuild the app -# +# # In order for this feature to work properly, you need to # build metamask-extension from source (https://github.com/MetaMask/metamask-extension) # and set your the same values there. -# +# # For more info take a look at https://github.com/MetaMask/metamask-extension/pull/5955 export MM_PUBNUB_SUB_KEY="" export MM_PUBNUB_PUB_KEY="" export MM_OPENSEA_KEY="" -export MM_ETHERSCAN_KEY="" export MM_FOX_CODE="EXAMPLE_FOX_CODE" # NOTE: Non-MetaMask only, will need to create an account and generate @@ -70,6 +69,10 @@ export SEGMENT_FLUSH_EVENT_LIMIT="1" # URL of security alerts API used to validate dApp requests. export SECURITY_ALERTS_API_URL="https://security-alerts.api.cx.metamask.io" +# Enable Portfolio View +export PORTFOLIO_VIEW="true" + + # Temporary mechanism to enable security alerts API prior to release. export MM_SECURITY_ALERTS_API_ENABLED="true" # Firebase @@ -81,7 +84,7 @@ export FCM_CONFIG_MESSAGING_SENDER_ID="" export FCM_CONFIG_APP_ID="" export GOOGLE_SERVICES_B64_ANDROID="" export GOOGLE_SERVICES_B64_IOS="" -#Notifications Feature Announcements +# Notifications Feature Announcements export FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN= export FEATURES_ANNOUNCEMENTS_SPACE_ID= @@ -96,8 +99,8 @@ export MM_PER_DAPP_SELECTED_NETWORK="" export MM_CHAIN_PERMISSIONS="" -#Multichain feature flag specific to UI changes +# Multichain feature flag specific to UI changes export MM_MULTICHAIN_V1_ENABLED="" -#Permissions Settings feature flag specific to UI changes +# Permissions Settings feature flag specific to UI changes export MM_PERMISSIONS_SETTINGS_V1_ENABLED="" diff --git a/CHANGELOG.md b/CHANGELOG.md index e735c29e177..155b760639f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,68 @@ ## Current Main Branch +## 7.37.0 - Nov 28, 2024 +### Added +- [#12091](https://github.com/MetaMask/metamask-mobile/pull/12091): feat: 2020 Add a performance test for iOS in Bitrise (#12091) +- [#12148](https://github.com/MetaMask/metamask-mobile/pull/12148): feat: Enable smart transactions for new users (#12148) +- [#12442](https://github.com/MetaMask/metamask-mobile/pull/12442): test: add a new unit test to cover for multichain feature flags ON (#12442) +- [#12420](https://github.com/MetaMask/metamask-mobile/pull/12420): feat(3598): non permitted chain flow small improvements (#12420) +- [#12198](https://github.com/MetaMask/metamask-mobile/pull/12198): feat: custom names for snap accounts (Flask only) (#12198) +- [#12396](https://github.com/MetaMask/metamask-mobile/pull/12396): feat(ramp): enable buy button in asset overview (#12396) +- [#11613](https://github.com/MetaMask/metamask-mobile/pull/11613): feat(ramp): improve amount editing formatting (#11613) +- [#12393](https://github.com/MetaMask/metamask-mobile/pull/12393): feat: Creating data tree for signed type V1 signatures (#12393) +- [#12160](https://github.com/MetaMask/metamask-mobile/pull/12160): feat: Integrate NFT api to display image & names in simulations includes `erc721`s (#12160) +- [#12324](https://github.com/MetaMask/metamask-mobile/pull/12324): feat: confirmation re-designs add basic page for types sign V1 signature request (#12324) +- [#11424](https://github.com/MetaMask/metamask-mobile/pull/11424): feat: add workflow for updating automated test results in TestRail (#11424) +- [#12337](https://github.com/MetaMask/metamask-mobile/pull/12337): feat: updated staking events to use withMetaMetrics helper (#12337) +- [#12363](https://github.com/MetaMask/metamask-mobile/pull/12363): feat: add PooledStaking slice for managing staking state (#12363) +- [#12398](https://github.com/MetaMask/metamask-mobile/pull/12398): feat: limit input digits to 12 in useInputHandler (#12398) +- [#12344](https://github.com/MetaMask/metamask-mobile/pull/12344): feat: upgrade assets controllers to v44 (#12344) +- [#12340](https://github.com/MetaMask/metamask-mobile/pull/12340): feat: upgrade assets controllers to version 43 (#12340) +- [#12270](https://github.com/MetaMask/metamask-mobile/pull/12270): feat: upgrade assets controllers to 42 with multichain token rates (#12270) +- [#12452](https://github.com/MetaMask/metamask-mobile/pull/12452): feat: updated staking events to use withMetaMetrics helper (#12337) (#12452) + +### Changed +- [#12356](https://github.com/MetaMask/metamask-mobile/pull/12356): chore: Remove unnecessary event prop (#12356) +- [#12425](https://github.com/MetaMask/metamask-mobile/pull/12425): ci: create ci workflow for multichain flow (#12425) +- [#12350](https://github.com/MetaMask/metamask-mobile/pull/12350): chore: Bump Snaps packages (#12350) +- [#11409](https://github.com/MetaMask/metamask-mobile/pull/11409): refactor: use `withKeyring` to batch account restore operation (#11409) +- [#12339](https://github.com/MetaMask/metamask-mobile/pull/12339): chore: Update accounts-controller @v19.0.0 and keyring-controller @v18.0.0 (#12339) +- [#12440](https://github.com/MetaMask/metamask-mobile/pull/12440): chore(ramp): upgrade sdk to 1.28.7 (#12440) +- [#12351](https://github.com/MetaMask/metamask-mobile/pull/12351): refactor(ramp): remove anonymous events (#12351) +- [#12355](https://github.com/MetaMask/metamask-mobile/pull/12355): chore: Add missing confirmation unit tests (#12355) +- [#12369](https://github.com/MetaMask/metamask-mobile/pull/12369): chore: upgrade transaction controller to increase polling rate (#12369) +- [#12202](https://github.com/MetaMask/metamask-mobile/pull/12202): refactor: update swaps quote poll count (#12202) +- [#10743](https://github.com/MetaMask/metamask-mobile/pull/10743): chore: @metamask/swaps-controller v9 -> v10 (#10743) +- [#12415](https://github.com/MetaMask/metamask-mobile/pull/12415): chore: Cherry pick 2506358 (merge in trackEvent work) (#12415) +- [#12238](https://github.com/MetaMask/metamask-mobile/pull/12238): chore: update codeowners (#12238) +- [#12416](https://github.com/MetaMask/metamask-mobile/pull/12416): chore: Chore/update accounts controller messenger code owner (#12416) +- [#12366](https://github.com/MetaMask/metamask-mobile/pull/12366): chore: #12184 MVP split engine file (#12366) +- [#12362](https://github.com/MetaMask/metamask-mobile/pull/12362): chore: Unit tests for tags approval controller undefined (#12362) +- [#12343](https://github.com/MetaMask/metamask-mobile/pull/12343): chore: Cherry pick f35d583 (#12343) +- [#12332](https://github.com/MetaMask/metamask-mobile/pull/12332): chore: do not show staked eth balance when balance is zero on homepage or asset detail (#12332) +- [#12413](https://github.com/MetaMask/metamask-mobile/pull/12413): chore: simplify cicd rls script (#12413) +- [#12334](https://github.com/MetaMask/metamask-mobile/pull/12334): chore: updating filter icon (#12334) + +### Fixed +- [#12313](https://github.com/MetaMask/metamask-mobile/pull/12313): fix: Remove run all tests section (#12313) +- [#12489](https://github.com/MetaMask/metamask-mobile/pull/12489): fix: replace end of navigation init and UIStartup span (#12489) +- [#12331](https://github.com/MetaMask/metamask-mobile/pull/12331): fix: tags pending approvals receiving undefined (#12331) +- [#10486](https://github.com/MetaMask/metamask-mobile/pull/10486): fix: limit ReactNativeWebview message size (#10486) +- [#12478](https://github.com/MetaMask/metamask-mobile/pull/12478): fix: incorrect event source in analytics and connection (#12478) +- [#10786](https://github.com/MetaMask/metamask-mobile/pull/10786): fix: added icon to walletconnect metadata (#10786) +- [#12455](https://github.com/MetaMask/metamask-mobile/pull/12455): fix: gas fee edit from swaps (#12455) +- [#12370](https://github.com/MetaMask/metamask-mobile/pull/12370): fix: Fix copy of ""Network fee"" on approval (#12370) +- [#12273](https://github.com/MetaMask/metamask-mobile/pull/12273): fix: Disable confirm button if `transactionMeta` is undefined (#12273) +- [#12367](https://github.com/MetaMask/metamask-mobile/pull/12367): fix: app crashing after send or swap (#12367) +- [#12446](https://github.com/MetaMask/metamask-mobile/pull/12446): fix: update wallet_addEthereumChain.js with correct MetricsEventBuilder (#12446) +- [#12180](https://github.com/MetaMask/metamask-mobile/pull/12180): fix: trackevent enabled is undefined (#12180) +- [#12315](https://github.com/MetaMask/metamask-mobile/pull/12315): fix: e2e: ensure Decrypt button is displayed (#12315) +- [#12402](https://github.com/MetaMask/metamask-mobile/pull/12402): fix: fix missing variable patch (#12402) +- [#12319](https://github.com/MetaMask/metamask-mobile/pull/12319): fix: hide rpc url selector for networks with one rpc (#12319) +- [#12371](https://github.com/MetaMask/metamask-mobile/pull/12371): fix: fix patch missing variable sentry error (#12371) +- [#12375](https://github.com/MetaMask/metamask-mobile/pull/12375): fix: breaking selector due to missing controller state (#12375) + ## 7.36.0 - Nov 15, 2024 ### Added - [#12015](https://github.com/MetaMask/metamask-mobile/pull/12015): feat: 1957 crash screen redesign (#12015) diff --git a/README.md b/README.md index 638b073f580..2f47d76233d 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,15 @@ yarn watch #### Download and install the development build -Go to the app's [GitHub Releases page](https://github.com/MetaMask/metamask-mobile/releases), download the latest release development build (android-expo-dev-build.apk or ios-expo-dev-build.ipa) and install it on an Android/iOS simulator or Android/iOS physical device. +#### For internal developers +- Access Runway via Okta and go to the Expo bucket either on the iOS or Android section. From there you will see the available development builds (android-expo-dev-build.apk or ios-expo-dev-build.ipa). +- For Android: + - Install the .apk on your Android device or simulator. +- For iOS: + - Device: you need to have your iPhone registered with our Apple dev account. If you have it, you can install the .ipa on your device. + - Simulator: please follow the [native development section](https://github.com/MetaMask/metamask-mobile?tab=readme-ov-file#native-development) and run `yarn setup` and `yarn start:ios` as the .ipa will not work for now, we are working on having an .app that works on simulators. + +##### [SOON] For external developers (we are testing the new dev builds and will make them publicly available soon after) #### Load the app diff --git a/android/app/build.gradle b/android/app/build.gradle index bbc2eef2ef6..c856cc0492f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -178,8 +178,8 @@ android { applicationId "io.metamask" minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 1505 - versionName "7.36.0" + versionName "7.37.0" + versionCode 1512 testBuildType System.getProperty('testBuildType', 'debug') missingDimensionStrategy 'react-native-camera', 'general' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/actions/navigation/index.ts b/app/actions/navigation/index.ts index 7a6fac7e9a0..b4c82b9a98e 100644 --- a/app/actions/navigation/index.ts +++ b/app/actions/navigation/index.ts @@ -1,18 +1,28 @@ /* eslint-disable import/prefer-default-export */ import { - SET_CURRENT_ROUTE, - SET_CURRENT_BOTTOM_NAV_ROUTE, -} from '../../reducers/navigation'; + type OnNavigationReadyAction, + type SetCurrentRouteAction, + type SetCurrentBottomNavRouteAction, + NavigationActionType, +} from './types'; -/** - * Action Creators - */ -export const setCurrentRoute = (route: string) => ({ - type: SET_CURRENT_ROUTE, +export * from './types'; + +export const setCurrentRoute = (route: string): SetCurrentRouteAction => ({ + type: NavigationActionType.SET_CURRENT_ROUTE, payload: { route }, }); -export const setCurrentBottomNavRoute = (route: string) => ({ - type: SET_CURRENT_BOTTOM_NAV_ROUTE, +export const setCurrentBottomNavRoute = ( + route: string, +): SetCurrentBottomNavRouteAction => ({ + type: NavigationActionType.SET_CURRENT_BOTTOM_NAV_ROUTE, payload: { route }, }); + +/** + * Action that is called when navigation is ready + */ +export const onNavigationReady = (): OnNavigationReadyAction => ({ + type: NavigationActionType.ON_NAVIGATION_READY, +}); diff --git a/app/actions/navigation/types.ts b/app/actions/navigation/types.ts new file mode 100644 index 00000000000..c57beba69da --- /dev/null +++ b/app/actions/navigation/types.ts @@ -0,0 +1,31 @@ +import { type Action } from 'redux'; + +/** + * Navigation action type enum + */ +export enum NavigationActionType { + ON_NAVIGATION_READY = 'ON_NAVIGATION_READY', + SET_CURRENT_ROUTE = 'SET_CURRENT_ROUTE', + SET_CURRENT_BOTTOM_NAV_ROUTE = 'SET_CURRENT_BOTTOM_NAV_ROUTE', +} + +export type OnNavigationReadyAction = + Action; + +export type SetCurrentRouteAction = + Action & { + payload: { route: string }; + }; + +export type SetCurrentBottomNavRouteAction = + Action & { + payload: { route: string }; + }; + +/** + * Navigation action + */ +export type NavigationAction = + | OnNavigationReadyAction + | SetCurrentRouteAction + | SetCurrentBottomNavRouteAction; diff --git a/app/actions/user/index.js b/app/actions/user/index.js deleted file mode 100644 index fd996b8707f..00000000000 --- a/app/actions/user/index.js +++ /dev/null @@ -1,134 +0,0 @@ -// Constants -export const LOCKED_APP = 'LOCKED_APP'; -export const AUTH_SUCCESS = 'AUTH_SUCCESS'; -export const AUTH_ERROR = 'AUTH_ERROR'; -export const INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS'; -export const LOGIN = 'LOGIN'; -export const LOGOUT = 'LOGOUT'; - -export function interruptBiometrics() { - return { - type: INTERRUPT_BIOMETRICS, - }; -} - -export function lockApp() { - return { - type: LOCKED_APP, - }; -} - -export function authSuccess(bioStateMachineId) { - return { - type: AUTH_SUCCESS, - payload: { bioStateMachineId }, - }; -} - -export function authError(bioStateMachineId) { - return { - type: AUTH_ERROR, - payload: { bioStateMachineId }, - }; -} - -export function passwordSet() { - return { - type: 'PASSWORD_SET', - }; -} - -export function passwordUnset() { - return { - type: 'PASSWORD_UNSET', - }; -} - -export function seedphraseBackedUp() { - return { - type: 'SEEDPHRASE_BACKED_UP', - }; -} - -export function seedphraseNotBackedUp() { - return { - type: 'SEEDPHRASE_NOT_BACKED_UP', - }; -} - -export function backUpSeedphraseAlertVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_VISIBLE', - }; -} - -export function backUpSeedphraseAlertNotVisible() { - return { - type: 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', - }; -} - -export function protectWalletModalVisible() { - return { - type: 'PROTECT_MODAL_VISIBLE', - }; -} - -export function protectWalletModalNotVisible() { - return { - type: 'PROTECT_MODAL_NOT_VISIBLE', - }; -} - -export function loadingSet(loadingMsg) { - return { - type: 'LOADING_SET', - loadingMsg, - }; -} - -export function loadingUnset() { - return { - type: 'LOADING_UNSET', - }; -} - -export function setGasEducationCarouselSeen() { - return { - type: 'SET_GAS_EDUCATION_CAROUSEL_SEEN', - }; -} - -export function logIn() { - return { - type: LOGIN, - }; -} - -export function logOut() { - return { - type: LOGOUT, - }; -} - -export function setAppTheme(theme) { - return { - type: 'SET_APP_THEME', - payload: { theme }, - }; -} - -/** - * Temporary action to control auth flow - * - * @param {string} initialScreen - "login" or "onboarding" - * @returns - void - */ -export function checkedAuth(initialScreen) { - return { - type: 'CHECKED_AUTH', - payload: { - initialScreen, - }, - }; -} diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts new file mode 100644 index 00000000000..9071fcffd50 --- /dev/null +++ b/app/actions/user/index.ts @@ -0,0 +1,161 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { + type InterruptBiometricsAction, + type LockAppAction, + type AuthSuccessAction, + type AuthErrorAction, + type PasswordSetAction, + type PasswordUnsetAction, + type SeedphraseBackedUpAction, + type SeedphraseNotBackedUpAction, + type BackUpSeedphraseVisibleAction, + type BackUpSeedphraseNotVisibleAction, + type ProtectModalVisibleAction, + type ProtectModalNotVisibleAction, + type LoadingSetAction, + type LoadingUnsetAction, + type SetGasEducationCarouselSeenAction, + type LoginAction, + type LogoutAction, + type SetAppThemeAction, + type CheckedAuthAction, + type PersistedDataLoadedAction, + UserActionType, +} from './types'; + +export * from './types'; + +export function interruptBiometrics(): InterruptBiometricsAction { + return { + type: UserActionType.INTERRUPT_BIOMETRICS, + }; +} + +export function lockApp(): LockAppAction { + return { + type: UserActionType.LOCKED_APP, + }; +} + +export function authSuccess(bioStateMachineId?: string): AuthSuccessAction { + return { + type: UserActionType.AUTH_SUCCESS, + payload: { bioStateMachineId }, + }; +} + +export function authError(bioStateMachineId?: string): AuthErrorAction { + return { + type: UserActionType.AUTH_ERROR, + payload: { bioStateMachineId }, + }; +} + +export function passwordSet(): PasswordSetAction { + return { + type: UserActionType.PASSWORD_SET, + }; +} + +export function passwordUnset(): PasswordUnsetAction { + return { + type: UserActionType.PASSWORD_UNSET, + }; +} + +export function seedphraseBackedUp(): SeedphraseBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_BACKED_UP, + }; +} + +export function seedphraseNotBackedUp(): SeedphraseNotBackedUpAction { + return { + type: UserActionType.SEEDPHRASE_NOT_BACKED_UP, + }; +} + +export function backUpSeedphraseAlertVisible(): BackUpSeedphraseVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_VISIBLE, + }; +} + +export function backUpSeedphraseAlertNotVisible(): BackUpSeedphraseNotVisibleAction { + return { + type: UserActionType.BACK_UP_SEEDPHRASE_NOT_VISIBLE, + }; +} + +export function protectWalletModalVisible(): ProtectModalVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_VISIBLE, + }; +} + +export function protectWalletModalNotVisible(): ProtectModalNotVisibleAction { + return { + type: UserActionType.PROTECT_MODAL_NOT_VISIBLE, + }; +} + +export function loadingSet(loadingMsg: string): LoadingSetAction { + return { + type: UserActionType.LOADING_SET, + loadingMsg, + }; +} + +export function loadingUnset(): LoadingUnsetAction { + return { + type: UserActionType.LOADING_UNSET, + }; +} + +export function setGasEducationCarouselSeen(): SetGasEducationCarouselSeenAction { + return { + type: UserActionType.SET_GAS_EDUCATION_CAROUSEL_SEEN, + }; +} + +export function logIn(): LoginAction { + return { + type: UserActionType.LOGIN, + }; +} + +export function logOut(): LogoutAction { + return { + type: UserActionType.LOGOUT, + }; +} + +export function setAppTheme(theme: AppThemeKey): SetAppThemeAction { + return { + type: UserActionType.SET_APP_THEME, + payload: { theme }, + }; +} + +/** + * Temporary action to control auth flow + * + * @param initialScreen - "login" or "onboarding" + */ +export function checkedAuth(initialScreen: string): CheckedAuthAction { + return { + type: UserActionType.CHECKED_AUTH, + payload: { + initialScreen, + }, + }; +} + +/** + * Action to signal that persisted data has been loaded + */ +export function onPersistedDataLoaded(): PersistedDataLoadedAction { + return { + type: UserActionType.ON_PERSISTED_DATA_LOADED, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts new file mode 100644 index 00000000000..704aee6092d --- /dev/null +++ b/app/actions/user/types.ts @@ -0,0 +1,111 @@ +import { type AppThemeKey } from '../../util/theme/models'; +import { type Action } from 'redux'; + +// Action type enum +export enum UserActionType { + LOCKED_APP = 'LOCKED_APP', + AUTH_SUCCESS = 'AUTH_SUCCESS', + AUTH_ERROR = 'AUTH_ERROR', + INTERRUPT_BIOMETRICS = 'INTERRUPT_BIOMETRICS', + LOGIN = 'LOGIN', + LOGOUT = 'LOGOUT', + ON_PERSISTED_DATA_LOADED = 'ON_PERSISTED_DATA_LOADED', + PASSWORD_SET = 'PASSWORD_SET', + PASSWORD_UNSET = 'PASSWORD_UNSET', + SEEDPHRASE_BACKED_UP = 'SEEDPHRASE_BACKED_UP', + SEEDPHRASE_NOT_BACKED_UP = 'SEEDPHRASE_NOT_BACKED_UP', + BACK_UP_SEEDPHRASE_VISIBLE = 'BACK_UP_SEEDPHRASE_VISIBLE', + BACK_UP_SEEDPHRASE_NOT_VISIBLE = 'BACK_UP_SEEDPHRASE_NOT_VISIBLE', + PROTECT_MODAL_VISIBLE = 'PROTECT_MODAL_VISIBLE', + PROTECT_MODAL_NOT_VISIBLE = 'PROTECT_MODAL_NOT_VISIBLE', + LOADING_SET = 'LOADING_SET', + LOADING_UNSET = 'LOADING_UNSET', + SET_GAS_EDUCATION_CAROUSEL_SEEN = 'SET_GAS_EDUCATION_CAROUSEL_SEEN', + SET_APP_THEME = 'SET_APP_THEME', + CHECKED_AUTH = 'CHECKED_AUTH', +} + +// User actions +export type LockAppAction = Action; + +export type AuthSuccessAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type AuthErrorAction = Action & { + payload: { bioStateMachineId?: string }; +}; + +export type InterruptBiometricsAction = + Action; + +export type LoginAction = Action; + +export type LogoutAction = Action; + +export type PersistedDataLoadedAction = + Action; + +export type PasswordSetAction = Action; + +export type PasswordUnsetAction = Action; + +export type SeedphraseBackedUpAction = + Action; + +export type SeedphraseNotBackedUpAction = + Action; + +export type BackUpSeedphraseVisibleAction = + Action; + +export type BackUpSeedphraseNotVisibleAction = + Action; + +export type ProtectModalVisibleAction = + Action; + +export type ProtectModalNotVisibleAction = + Action; + +export type LoadingSetAction = Action & { + loadingMsg: string; +}; + +export type LoadingUnsetAction = Action; + +export type SetGasEducationCarouselSeenAction = + Action; + +export type SetAppThemeAction = Action & { + payload: { theme: AppThemeKey }; +}; + +export type CheckedAuthAction = Action & { + payload: { initialScreen: string }; +}; + +/** + * User actions union type + */ +export type UserAction = + | LockAppAction + | AuthSuccessAction + | AuthErrorAction + | InterruptBiometricsAction + | LoginAction + | LogoutAction + | PersistedDataLoadedAction + | PasswordSetAction + | PasswordUnsetAction + | SeedphraseBackedUpAction + | SeedphraseNotBackedUpAction + | BackUpSeedphraseVisibleAction + | BackUpSeedphraseNotVisibleAction + | ProtectModalVisibleAction + | ProtectModalNotVisibleAction + | LoadingSetAction + | LoadingUnsetAction + | SetGasEducationCarouselSeenAction + | SetAppThemeAction + | CheckedAuthAction; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 7218385de28..32ec533e3ae 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -44,6 +44,7 @@ import { getVersion } from 'react-native-device-info'; import { setCurrentBottomNavRoute, setCurrentRoute, + onNavigationReady, } from '../../../actions/navigation'; import { findRouteNameFromNavigatorState } from '../../../util/general'; import { Authentication } from '../../../core/'; @@ -880,6 +881,11 @@ const App = (props) => { } }; + /** + * Triggers when the navigation is ready + */ + const onNavigationReadyHandler = () => dispatch(onNavigationReady()); + return supressRender ? null : ( <> { @@ -905,6 +911,7 @@ const App = (props) => { const currentRoute = findRouteNameFromNavigatorState(state.routes); triggerSetCurrentRoute(currentRoute); }} + onReady={onNavigationReadyHandler} > { ); const parameters = { - ...analyticsParams, time_to_mine: timeToMine, estimated_vs_used_gasRatio: estimatedVsUsedGasRatio, quote_vs_executionRatio: quoteVsExecutionRatio, token_to_amount_received: tokenToAmountReceived.toString(), is_smart_transaction: props.shouldUseSmartTransaction, ...smartTransactionMetricsProperties, + available_quotes: analyticsParams.available_quotes, + best_quote_source: analyticsParams.best_quote_source, + chain_id: analyticsParams.chain_id, + custom_slippage: analyticsParams.custom_slippage, + network_fees_USD: analyticsParams.network_fees_USD, + other_quote_selected: analyticsParams.other_quote_selected, + request_type: analyticsParams.request_type, + token_from: analyticsParams.token_from, + token_to: analyticsParams.token_to, + }; + const sensitiveParameters = { + token_from_amount: analyticsParams.token_from_amount, + token_to_amount: analyticsParams.token_to_amount, + network_fees_ETH: analyticsParams.network_fees_ETH, }; Logger.log('Swaps', 'Sending metrics event', event); trackEvent( createEventBuilder(event) - .addSensitiveProperties({ ...parameters }) + .addProperties({ ...parameters }) + .addSensitiveProperties({ ...sensitiveParameters }) .build(), ); } catch (e) { diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index 5330bfdb1ea..b8ba760da0c 100644 --- a/app/components/Nav/Main/index.js +++ b/app/components/Nav/Main/index.js @@ -133,7 +133,7 @@ const Main = (props) => { stopIncomingTransactionPolling(); if (showIncomingTransactionsNetworks[chainId]) { - startIncomingTransactionPolling([networkClientId]); + startIncomingTransactionPolling([chainId]); } }, [chainId, networkClientId, showIncomingTransactionsNetworks]); @@ -178,11 +178,11 @@ const Main = (props) => { removeNotVisibleNotifications(); BackgroundTimer.runBackgroundTimer(async () => { - await updateIncomingTransactions([props.networkClientId]); + await updateIncomingTransactions([props.chainId]); }, AppConstants.TX_CHECK_BACKGROUND_FREQUENCY); } }, - [backgroundMode, removeNotVisibleNotifications, props.networkClientId], + [backgroundMode, removeNotVisibleNotifications, props.chainId], ); const initForceReload = () => { diff --git a/app/components/UI/AccountApproval/index.test.tsx b/app/components/UI/AccountApproval/index.test.tsx index 0afe79dd39e..29e438ead16 100644 --- a/app/components/UI/AccountApproval/index.test.tsx +++ b/app/components/UI/AccountApproval/index.test.tsx @@ -48,6 +48,13 @@ const mockInitialState = { }, }, }, + TokensController: { + allTokens: { + '0x1': { + '0xc4966c0d659d99699bfd7eb54d8fafee40e4a756': [], + }, + }, + }, }, }, }; diff --git a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx index 68db9cb046b..c650e8640f5 100644 --- a/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx +++ b/app/components/UI/AccountFromToInfoCard/AccountFromToInfoCard.test.tsx @@ -38,7 +38,13 @@ const mockInitialState: DeepPartial = { }, }, TokenBalancesController: { - tokenBalances: { }, + tokenBalances: { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': { + '0x5': { + '0x326836cc6cd09B5aa59B81A7F72F25FcC0136b95': '0x2b46', + }, + }, + }, }, AccountsController: MOCK_ACCOUNTS_CONTROLLER_STATE, }, diff --git a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx index ec3a6c860fd..ad88315241f 100644 --- a/app/components/UI/AccountSelectorList/AccountSelector.test.tsx +++ b/app/components/UI/AccountSelectorList/AccountSelector.test.tsx @@ -18,6 +18,9 @@ import { mockNetworkState } from '../../../util/test/network'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { AccountSelectorListProps } from './AccountSelectorList.types'; +// eslint-disable-next-line import/no-namespace +import * as Utils from '../../hooks/useAccounts/utils'; + const BUSINESS_ACCOUNT = '0xC4955C0d639D99699Bfd7Ec54d9FaFEe40e4D272'; const PERSONAL_ACCOUNT = '0xd018538C87232FF95acbCe4870629b75640a78E7'; @@ -125,6 +128,16 @@ const renderComponent = ( describe('AccountSelectorList', () => { beforeEach(() => { + jest.spyOn(Utils, 'getAccountBalances').mockReturnValueOnce({ + balanceETH: '1', + balanceFiat: '$3200.00', + balanceWeiHex: '', + }); + jest.spyOn(Utils, 'getAccountBalances').mockReturnValueOnce({ + balanceETH: '2', + balanceFiat: '$6400.00', + balanceWeiHex: '', + }); onSelectAccount.mockClear(); onRemoveImportedAccount.mockClear(); }); diff --git a/app/components/UI/AssetOverview/AssetOverview.test.tsx b/app/components/UI/AssetOverview/AssetOverview.test.tsx index e84206b8c13..8be0735dace 100644 --- a/app/components/UI/AssetOverview/AssetOverview.test.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.test.tsx @@ -10,8 +10,13 @@ import { MOCK_ADDRESS_2, } from '../../../util/test/accountsControllerTestUtils'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; +// eslint-disable-next-line import/no-namespace +import * as networks from '../../../util/networks'; const MOCK_CHAIN_ID = '0x1'; @@ -43,6 +48,15 @@ const mockInitialState = { }, } as const, }, + CurrencyRateController: { + conversionRate: { + ETH: { + conversionDate: 1732572535.47, + conversionRate: 3432.53, + usdConversionRate: 3432.53, + }, + }, + }, }, settings: { primaryCurrency: 'ETH', @@ -51,6 +65,15 @@ const mockInitialState = { const mockNavigate = jest.fn(); const navigate = mockNavigate; +const mockNetworkConfiguration = { + rpcEndpoints: [ + { + networkClientId: 'mockNetworkClientId', + }, + ], + defaultRpcEndpointIndex: 0, +}; + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -72,9 +95,21 @@ jest.mock('../../hooks/useStyles', () => ({ }), })); +jest.mock('../../../core/Engine', () => ({ + context: { + NetworkController: { + getNetworkConfigurationByChainId: jest + .fn() + .mockReturnValue(mockNetworkConfiguration), + setActiveNetwork: jest.fn().mockResolvedValue(undefined), + }, + }, +})); + const asset = { balance: '400', balanceFiat: '1500', + chainId: MOCK_CHAIN_ID, logo: 'https://upload.wikimedia.org/wikipedia/commons/0/05/Ethereum_logo_2014.svg', symbol: 'ETH', name: 'Ethereum', @@ -87,6 +122,10 @@ const asset = { }; describe('AssetOverview', () => { + beforeEach(() => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(false); + }); + it('should render correctly', async () => { const container = renderWithProvider( , @@ -95,6 +134,16 @@ describe('AssetOverview', () => { expect(container).toMatchSnapshot(); }); + it('should render correctly when portfolio view is enabled', async () => { + jest.spyOn(networks, 'isPortfolioViewEnabled').mockReturnValue(true); + + const container = renderWithProvider( + , + { state: mockInitialState }, + ); + expect(container).toMatchSnapshot(); + }); + it('should handle buy button press', async () => { const { getByTestId } = renderWithProvider( , @@ -133,13 +182,34 @@ describe('AssetOverview', () => { const swapButton = getByTestId('token-swap-button'); fireEvent.press(swapButton); - expect(navigate).toHaveBeenCalledWith('Swaps', { - params: { - sourcePage: 'MainView', - sourceToken: asset.address, - }, - screen: 'SwapsAmountView', - }); + if (isPortfolioViewEnabled()) { + expect(navigate).toHaveBeenCalledTimes(3); + expect(navigate).toHaveBeenNthCalledWith(1, 'RampBuy', { + screen: 'GetStarted', + params: { + address: asset.address, + chainId: getDecimalChainId(MOCK_CHAIN_ID), + }, + }); + expect(navigate).toHaveBeenNthCalledWith(2, 'SendFlowView', {}); + expect(navigate).toHaveBeenNthCalledWith(3, 'Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + address: asset.address, + chainId: MOCK_CHAIN_ID, + }, + }); + } else { + expect(navigate).toHaveBeenCalledWith('Swaps', { + screen: 'SwapsAmountView', + params: { + sourcePage: 'MainView', + sourceToken: asset.address, + chainId: '0x1', + }, + }); + } }); it('should not render swap button if displaySwapsButton is false', async () => { diff --git a/app/components/UI/AssetOverview/AssetOverview.tsx b/app/components/UI/AssetOverview/AssetOverview.tsx index 55ce2b8f222..cbcd738dca6 100644 --- a/app/components/UI/AssetOverview/AssetOverview.tsx +++ b/app/components/UI/AssetOverview/AssetOverview.tsx @@ -1,8 +1,9 @@ -import { zeroAddress } from 'ethereumjs-util'; import React, { useCallback, useEffect } from 'react'; import { TouchableOpacity, View } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { useDispatch, useSelector } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import { getNativeTokenAddress } from '@metamask/assets-controllers'; import { strings } from '../../../../locales/i18n'; import { TokenOverviewSelectorsIDs } from '../../../../e2e/selectors/wallet/TokenOverview.selectors'; import { newAssetTransaction } from '../../../actions/transaction'; @@ -11,15 +12,26 @@ import Engine from '../../../core/Engine'; import { selectChainId, selectTicker, + selectNativeCurrencyByChainId, } from '../../../selectors/networkController'; import { selectConversionRate, selectCurrentCurrency, + selectCurrencyRates, } from '../../../selectors/currencyRateController'; -import { selectContractExchangeRates } from '../../../selectors/tokenRatesController'; +import { + selectContractExchangeRates, + selectTokenMarketData, +} from '../../../selectors/tokenRatesController'; import { selectAccountsByChainId } from '../../../selectors/accountTrackerController'; -import { selectContractBalances } from '../../../selectors/tokenBalancesController'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; +import { + selectContractBalances, + selectTokensBalances, +} from '../../../selectors/tokenBalancesController'; +import { + selectSelectedInternalAccountAddress, + selectSelectedInternalAccountFormattedAddress, +} from '../../../selectors/accountsController'; import Logger from '../../../util/Logger'; import { safeToChecksumAddress } from '../../../util/address'; import { @@ -46,9 +58,12 @@ import Routes from '../../../constants/navigation/Routes'; import TokenDetails from './TokenDetails'; import { RootState } from '../../../reducers'; import useGoToBridge from '../Bridge/utils/useGoToBridge'; -import SwapsController from '@metamask/swaps-controller'; +import SwapsController, { swapsUtils } from '@metamask/swaps-controller'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { getDecimalChainId } from '../../../util/networks'; +import { + getDecimalChainId, + isPortfolioViewEnabled, +} from '../../../util/networks'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { createBuyNavigationDetails } from '../Ramp/routes/utils'; import { TokenI } from '../Tokens/types'; @@ -67,8 +82,12 @@ const AssetOverview: React.FC = ({ }: AssetOverviewProps) => { const navigation = useNavigation(); const [timePeriod, setTimePeriod] = React.useState('1d'); - const currentCurrency = useSelector(selectCurrentCurrency); + const selectedInternalAccountAddress = useSelector( + selectSelectedInternalAccountAddress, + ); const conversionRate = useSelector(selectConversionRate); + const conversionRateByTicker = useSelector(selectCurrencyRates); + const currentCurrency = useSelector(selectCurrentCurrency); const accountsByChainId = useSelector(selectAccountsByChainId); const primaryCurrency = useSelector( (state: RootState) => state.settings.primaryCurrency, @@ -79,12 +98,35 @@ const AssetOverview: React.FC = ({ ); const { trackEvent, createEventBuilder } = useMetrics(); const tokenExchangeRates = useSelector(selectContractExchangeRates); + const allTokenMarketData = useSelector(selectTokenMarketData); const tokenBalances = useSelector(selectContractBalances); - const chainId = useSelector((state: RootState) => selectChainId(state)); - const ticker = useSelector((state: RootState) => selectTicker(state)); + const selectedChainId = useSelector((state: RootState) => + selectChainId(state), + ); + const selectedTicker = useSelector((state: RootState) => selectTicker(state)); + + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + + const multiChainTokenBalance = useSelector(selectTokensBalances); + const chainId = isPortfolioViewEnabled() + ? (asset.chainId as Hex) + : selectedChainId; + const ticker = isPortfolioViewEnabled() ? nativeCurrency : selectedTicker; + + let currentAddress: Hex; + + if (isPortfolioViewEnabled()) { + currentAddress = asset.address as Hex; + } else { + currentAddress = asset.isETH + ? getNativeTokenAddress(chainId as Hex) + : (asset.address as Hex); + } const { data: prices = [], isLoading } = useTokenHistoricalPrices({ - address: asset.isETH ? zeroAddress() : asset.address, + address: currentAddress, chainId, timePeriod, vsCurrency: currentCurrency, @@ -119,7 +161,41 @@ const AssetOverview: React.FC = ({ }); }; + const handleSwapNavigation = useCallback(() => { + navigation.navigate('Swaps', { + screen: 'SwapsAmountView', + params: { + sourceToken: asset.address ?? swapsUtils.NATIVE_SWAPS_TOKEN_ADDRESS, + sourcePage: 'MainView', + chainId: asset.chainId, + }, + }); + }, [navigation, asset.address, asset.chainId]); + const onSend = async () => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + await NetworkController.setActiveNetwork(networkClientId as string); + } + } if (asset.isETH && ticker) { dispatch(newAssetTransaction(getEther(ticker))); } else { @@ -128,25 +204,58 @@ const AssetOverview: React.FC = ({ navigation.navigate('SendFlowView', {}); }; - const goToSwaps = () => { - navigation.navigate('Swaps', { - screen: 'SwapsAmountView', - params: { - sourceToken: asset.address, - sourcePage: 'MainView', - }, - }); - trackEvent( - createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) - .addProperties({ - text: 'Swap', - tokenSymbol: '', - location: 'TokenDetails', - chain_id: getDecimalChainId(chainId), - }) - .build(), - ); - }; + const goToSwaps = useCallback(() => { + if (isPortfolioViewEnabled()) { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + if (asset.chainId !== selectedChainId) { + const { NetworkController } = Engine.context; + const networkConfiguration = + NetworkController.getNetworkConfigurationByChainId( + asset.chainId as Hex, + ); + + const networkClientId = + networkConfiguration?.rpcEndpoints?.[ + networkConfiguration.defaultRpcEndpointIndex + ]?.networkClientId; + + NetworkController.setActiveNetwork(networkClientId as string).then( + () => { + setTimeout(() => { + handleSwapNavigation(); + }, 500); + }, + ); + } else { + handleSwapNavigation(); + } + } else { + handleSwapNavigation(); + trackEvent( + createEventBuilder(MetaMetricsEvents.SWAP_BUTTON_CLICKED) + .addProperties({ + text: 'Swap', + tokenSymbol: '', + location: 'TokenDetails', + chain_id: getDecimalChainId(asset.chainId), + }) + .build(), + ); + } + }, [ + navigation, + asset.chainId, + selectedChainId, + trackEvent, + createEventBuilder, + handleSwapNavigation, + ]); + const onBuy = () => { navigation.navigate( ...createBuyNavigationDetails({ @@ -209,14 +318,21 @@ const AssetOverview: React.FC = ({ )), [handleSelectTimePeriod, timePeriod], ); - const itemAddress = safeToChecksumAddress(asset.address); - const exchangeRate = itemAddress - ? tokenExchangeRates?.[itemAddress]?.price - : undefined; + + let exchangeRate: number | undefined; + if (!isPortfolioViewEnabled()) { + exchangeRate = itemAddress + ? tokenExchangeRates?.[itemAddress as Hex]?.price + : undefined; + } else { + const currentChainId = chainId as Hex; + exchangeRate = + allTokenMarketData?.[currentChainId]?.[itemAddress as Hex]?.price; + } let balance, balanceFiat; - if (asset.isETH) { + if (asset.isETH || asset.isNative) { balance = renderFromWei( //@ts-expect-error - This should be fixed at the accountsController selector level, ongoing discussion accountsByChainId[toHexadecimal(chainId)][selectedAddress]?.balance, @@ -230,9 +346,22 @@ const AssetOverview: React.FC = ({ currentCurrency, ); } else { + const multiChainTokenBalanceHex = + itemAddress && + multiChainTokenBalance?.[selectedInternalAccountAddress as Hex]?.[ + chainId as Hex + ]?.[itemAddress as Hex]; + + const selectedTokenBalanceHex = + itemAddress && tokenBalances?.[itemAddress as Hex]; + + const tokenBalanceHex = isPortfolioViewEnabled() + ? multiChainTokenBalanceHex + : selectedTokenBalanceHex; + balance = - itemAddress && tokenBalances?.[itemAddress] - ? renderFromTokenMinimalUnit(tokenBalances[itemAddress], asset.decimals) + itemAddress && tokenBalanceHex + ? renderFromTokenMinimalUnit(tokenBalanceHex, asset.decimals) : 0; balanceFiat = balanceToFiat( balance, @@ -243,23 +372,37 @@ const AssetOverview: React.FC = ({ } let mainBalance, secondaryBalance; - if (primaryCurrency === 'ETH') { - mainBalance = `${balance} ${asset.symbol}`; - secondaryBalance = balanceFiat; + if (!isPortfolioViewEnabled()) { + if (primaryCurrency === 'ETH') { + mainBalance = `${balance} ${asset.symbol}`; + secondaryBalance = balanceFiat; + } else { + mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; + secondaryBalance = !balanceFiat + ? balanceFiat + : `${balance} ${asset.symbol}`; + } } else { - mainBalance = !balanceFiat ? `${balance} ${asset.symbol}` : balanceFiat; - secondaryBalance = !balanceFiat - ? balanceFiat - : `${balance} ${asset.symbol}`; + mainBalance = `${balance} ${asset.isETH ? asset.ticker : asset.symbol}`; + secondaryBalance = exchangeRate ? asset.balanceFiat : ''; } let currentPrice = 0; let priceDiff = 0; - if (asset.isETH) { - currentPrice = conversionRate || 0; - } else if (exchangeRate && conversionRate) { - currentPrice = exchangeRate * conversionRate; + if (!isPortfolioViewEnabled()) { + if (asset.isETH) { + currentPrice = conversionRate || 0; + } else if (exchangeRate && conversionRate) { + currentPrice = exchangeRate * conversionRate; + } + } else { + const tickerConversionRate = + conversionRateByTicker?.[nativeCurrency]?.conversionRate ?? 0; + currentPrice = + exchangeRate && tickerConversionRate + ? exchangeRate * tickerConversionRate + : 0; } const comparePrice = prices[0]?.[1] || 0; diff --git a/app/components/UI/AssetOverview/Balance/Balance.tsx b/app/components/UI/AssetOverview/Balance/Balance.tsx index afc9b9379af..82662417e44 100644 --- a/app/components/UI/AssetOverview/Balance/Balance.tsx +++ b/app/components/UI/AssetOverview/Balance/Balance.tsx @@ -14,7 +14,7 @@ import { isLineaMainnetByChainId, isMainnetByChainId, isTestNet, - isPortfolioViewEnabledFunction, + isPortfolioViewEnabled, } from '../../../../util/networks'; import images from '../../../../images/image-icons'; import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; @@ -46,7 +46,7 @@ interface BalanceProps { export const NetworkBadgeSource = (chainId: Hex, ticker: string) => { const isMainnet = isMainnetByChainId(chainId); const isLineaMainnet = isLineaMainnetByChainId(chainId); - if (!isPortfolioViewEnabledFunction()) { + if (!isPortfolioViewEnabled()) { if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); if (isMainnet) return images.ETHEREUM; @@ -95,14 +95,16 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { const networkName = useSelector(selectNetworkName); const chainId = useSelector(selectChainId); + const tokenChainId = isPortfolioViewEnabled() ? asset.chainId : chainId; + const ticker = asset.symbol; const renderNetworkAvatar = useCallback(() => { - if (!isPortfolioViewEnabledFunction() && asset.isETH) { + if (!isPortfolioViewEnabled() && asset.isETH) { return ; } - if (isPortfolioViewEnabledFunction() && asset.isNative) { + if (isPortfolioViewEnabled() && asset.isNative) { return ( { balance={secondaryBalance} onPress={() => !asset.isETH && + !asset.isNative && navigation.navigate('AssetDetails', { chainId: asset.chainId, address: asset.address, @@ -153,8 +156,8 @@ const Balance = ({ asset, mainBalance, secondaryBalance }: BalanceProps) => { badgeElement={ } > diff --git a/app/components/UI/AssetOverview/Balance/index.test.tsx b/app/components/UI/AssetOverview/Balance/index.test.tsx index f3070b52a3f..ba5dd5f2fc3 100644 --- a/app/components/UI/AssetOverview/Balance/index.test.tsx +++ b/app/components/UI/AssetOverview/Balance/index.test.tsx @@ -8,7 +8,7 @@ import { Provider, useSelector } from 'react-redux'; import configureMockStore from 'redux-mock-store'; import { backgroundState } from '../../../../util/test/initial-root-state'; import { NetworkBadgeSource } from './Balance'; -import { isPortfolioViewEnabledFunction } from '../../../../util/networks'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -37,6 +37,8 @@ const mockDAI = { symbol: 'DAI', isETH: false, logo: 'image-path', + chainId: '0x1', + isNative: false, }; const mockETH = { @@ -52,6 +54,8 @@ const mockETH = { symbol: 'ETH', isETH: true, logo: 'image-path', + chainId: '0x1', + isNative: true, }; const mockInitialState = { @@ -67,7 +71,7 @@ jest.mock('../../../../util/networks', () => ({ jest.mock('../../../../util/networks', () => ({ ...jest.requireActual('../../../../util/networks'), - isPortfolioViewEnabledFunction: jest.fn(), + isPortfolioViewEnabled: jest.fn(), })); describe('Balance', () => { @@ -95,23 +99,27 @@ describe('Balance', () => { jest.clearAllMocks(); }); - it('should render correctly with a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); - - it('should render correctly without a fiat balance', () => { - const wrapper = render( - , - ); - expect(wrapper).toMatchSnapshot(); - }); + if (!isPortfolioViewEnabled()) { + it('should render correctly with a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } + + if (!isPortfolioViewEnabled()) { + it('should render correctly without a fiat balance', () => { + const wrapper = render( + , + ); + expect(wrapper).toMatchSnapshot(); + }); + } it('should fire navigation event for non native tokens', () => { const { queryByTestId } = render( @@ -155,11 +163,39 @@ describe('Balance', () => { }); it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { - (isPortfolioViewEnabledFunction as jest.Mock).mockImplementation( - () => true, - ); + if (isPortfolioViewEnabled()) { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + } + }); + }); +}); + +describe('NetworkBadgeSource', () => { + it('returns testnet image for a testnet chainId', () => { + const result = NetworkBadgeSource('0xaa36a7', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns mainnet Ethereum image for mainnet chainId', () => { + const result = NetworkBadgeSource('0x1', 'ETH'); + expect(result).toBeDefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId', () => { + const result = NetworkBadgeSource('0xe708', 'LINEA'); + expect(result).toBeDefined(); + }); + + it('returns undefined if no image is found', () => { + const result = NetworkBadgeSource('0x999', 'UNKNOWN'); + expect(result).toBeUndefined(); + }); + + it('returns Linea Mainnet image for Linea mainnet chainId isPortfolioViewEnabled is true', () => { + if (isPortfolioViewEnabled()) { const result = NetworkBadgeSource('0xe708', 'LINEA'); expect(result).toBeDefined(); - }); + } }); }); diff --git a/app/components/UI/AssetOverview/Price/Price.tsx b/app/components/UI/AssetOverview/Price/Price.tsx index 867441a9a87..9e65e259d4c 100644 --- a/app/components/UI/AssetOverview/Price/Price.tsx +++ b/app/components/UI/AssetOverview/Price/Price.tsx @@ -90,7 +90,10 @@ const Price = ({ {asset.symbol} )} {!isNaN(price) && ( - + {isLoading ? ( diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx index 7e8d341492d..881977207bb 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.test.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Hex } from '@metamask/utils'; +import { MarketDataDetails } from '@metamask/assets-controllers'; import renderWithProvider from '../../../../util/test/renderWithProvider'; import { backgroundState } from '../../../../util/test/initial-root-state'; import TokenDetails from './'; @@ -8,9 +10,12 @@ import { selectConversionRate, selectCurrentCurrency, } from '../../../../selectors/currencyRateController'; +import { + selectProviderConfig, + selectTicker, +} from '../../../../selectors/networkController'; // eslint-disable-next-line import/no-namespace import * as reactRedux from 'react-redux'; - jest.mock('../../../../core/Engine', () => ({ getTotalFiatAccountBalance: jest.fn(), context: { @@ -80,14 +85,60 @@ const mockContractExchangeRates = { }, }; +const mockTokenMarketDataByChainId: Record< + Hex, + Record +> = { + '0x1': { + '0x6B175474E89094C44Da98b954EedeAC495271d0F': { + allTimeHigh: 0.00045049491236145674, + allTimeLow: 0.00032567089582484455, + circulatingSupply: 5210102796.32321, + currency: 'ETH', + dilutedMarketCap: 1923097.9291743594, + high1d: 0.0003703658992610993, + low1d: 0.00036798603064620616, + marketCap: 1923097.9291743594, + marketCapPercentChange1d: -0.03026, + price: 0.00036902069191213795, + priceChange1d: 0.00134711, + pricePercentChange14d: -0.01961306580879152, + pricePercentChange1d: 0.13497913251736524, + pricePercentChange1h: -0.15571963819527113, + pricePercentChange1y: -0.01608509228365429, + pricePercentChange200d: -0.0287692372426721, + pricePercentChange30d: -0.08401729203937018, + pricePercentChange7d: 0.019578202262256407, + tokenAddress: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + totalVolume: 54440.464606773865, + }, + }, +}; + describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); it('should render correctly', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -133,10 +184,26 @@ describe('TokenDetails', () => { expect(toJSON()).toMatchSnapshot(); }); - it('should render TokenDetils without MarketDetails when marketData is null', () => { + it('should render Token Details without Market Details when marketData is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: {}, + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + useSelectorSpy.mockImplementation((selectorOrCallback) => { + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return mockAssets; case selectContractExchangeRates: @@ -145,6 +212,10 @@ describe('TokenDetails', () => { return mockExchangeRate; case selectCurrentCurrency: return mockCurrentCurrency; + case selectProviderConfig: + return { ticker: 'ETH' }; + case selectTicker: + return 'ETH'; default: return undefined; } @@ -162,8 +233,24 @@ describe('TokenDetails', () => { it('should render MarketDetails without TokenDetails when tokenList is null', () => { const useSelectorSpy = jest.spyOn(reactRedux, 'useSelector'); - useSelectorSpy.mockImplementation((selector) => { - switch (selector) { + useSelectorSpy.mockImplementation((selectorOrCallback) => { + const SELECTOR_MOCKS = { + selectTokenMarketDataByChainId: mockTokenMarketDataByChainId['0x1'], + selectConversionRateBySymbol: mockExchangeRate, + selectNativeCurrencyByChainId: 'ETH', + } as const; + + if (typeof selectorOrCallback === 'function') { + const selectorString = selectorOrCallback.toString(); + const matchedSelector = Object.keys(SELECTOR_MOCKS).find((key) => + selectorString.includes(key), + ); + if (matchedSelector) { + return SELECTOR_MOCKS[matchedSelector as keyof typeof SELECTOR_MOCKS]; + } + } + + switch (selectorOrCallback) { case selectTokenList: return {}; case selectContractExchangeRates: @@ -179,9 +266,7 @@ describe('TokenDetails', () => { const { getByText, queryByText } = renderWithProvider( , - { - state: initialState, - }, + { state: initialState }, ); expect(queryByText('Token details')).toBeNull(); expect(getByText('Market details')).toBeDefined(); diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx index 368e2352d23..54df1781873 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetails.tsx @@ -1,4 +1,6 @@ import { zeroAddress } from 'ethereumjs-util'; +import { Hex } from '@metamask/utils'; +import { RootState } from '../../../../reducers'; import React from 'react'; import { View } from 'react-native'; import { useSelector } from 'react-redux'; @@ -7,11 +9,16 @@ import { useStyles } from '../../../../component-library/hooks'; import styleSheet from './TokenDetails.styles'; import { safeToChecksumAddress } from '../../../../util/address'; import { selectTokenList } from '../../../../selectors/tokenListController'; -import { selectContractExchangeRates } from '../../../../selectors/tokenRatesController'; import { - selectConversionRate, + selectTokenMarketDataByChainId, + selectContractExchangeRates, +} from '../../../../selectors/tokenRatesController'; +import { + selectConversionRateBySymbol, selectCurrentCurrency, + selectConversionRate, } from '../../../../selectors/currencyRateController'; +import { selectNativeCurrencyByChainId } from '../../../../selectors/networkController'; import { convertDecimalToPercentage, localizeLargeNumber, @@ -23,6 +30,7 @@ import MarketDetailsList from './MarketDetailsList'; import { TokenI } from '../../Tokens/types'; import { isPooledStakingFeatureEnabled } from '../../Stake/constants'; import StakingEarnings from '../../Stake/components/StakingEarnings'; +import { isPortfolioViewEnabled } from '../../../../util/networks'; export interface TokenDetails { contractAddress: string | null; @@ -46,20 +54,36 @@ interface TokenDetailsProps { const TokenDetails: React.FC = ({ asset }) => { const { styles } = useStyles(styleSheet, {}); - const tokenList = useSelector(selectTokenList); - const tokenExchangeRates = useSelector(selectContractExchangeRates); - const conversionRate = useSelector(selectConversionRate); + const tokenExchangeRatesByChainId = useSelector((state: RootState) => + selectTokenMarketDataByChainId(state, asset.chainId as Hex), + ); + const nativeCurrency = useSelector((state: RootState) => + selectNativeCurrencyByChainId(state, asset.chainId as Hex), + ); + const tokenExchangeRatesLegacy = useSelector(selectContractExchangeRates); + const conversionRateLegacy = useSelector(selectConversionRate); + const conversionRateBySymbol = useSelector((state: RootState) => + selectConversionRateBySymbol(state, nativeCurrency), + ); const currentCurrency = useSelector(selectCurrentCurrency); const tokenContractAddress = safeToChecksumAddress(asset.address); + const tokenList = useSelector(selectTokenList); + + const conversionRate = isPortfolioViewEnabled() + ? conversionRateBySymbol + : conversionRateLegacy; + const tokenExchangeRates = isPortfolioViewEnabled() + ? tokenExchangeRatesByChainId + : tokenExchangeRatesLegacy; let tokenMetadata; let marketData; if (asset.isETH) { - marketData = tokenExchangeRates?.[zeroAddress() as `0x${string}`]; - } else if (!asset.isETH && tokenContractAddress) { + marketData = tokenExchangeRates?.[zeroAddress() as Hex]; + } else if (tokenContractAddress) { tokenMetadata = tokenList?.[tokenContractAddress.toLowerCase()]; - marketData = tokenExchangeRates?.[tokenContractAddress]; + marketData = tokenExchangeRates?.[tokenContractAddress as Hex]; } else { Logger.log('cannot find contract address'); return null; diff --git a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx index 1b0c37923d0..64f1e8da9d0 100644 --- a/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx +++ b/app/components/UI/AssetOverview/TokenDetails/TokenDetailsList/TokenDetailsList.test.tsx @@ -19,6 +19,7 @@ describe('TokenDetails', () => { beforeAll(() => { jest.resetAllMocks(); }); + it('should render correctly', () => { const useDispatchSpy = jest.spyOn(reactRedux, 'useDispatch'); useDispatchSpy.mockImplementation(() => jest.fn()); diff --git a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap index cc3cbd21a25..c1eba443d22 100644 --- a/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap +++ b/app/components/UI/AssetOverview/__snapshots__/AssetOverview.test.tsx.snap @@ -1133,3 +1133,1139 @@ exports[`AssetOverview should render correctly 1`] = ` `; + +exports[`AssetOverview should render correctly when portfolio view is enabled 1`] = ` + + + + + Ethereum + ( + ETH + ) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1D + + + + + 1W + + + + + 1M + + + + + 3M + + + + + 1Y + + + + + 3Y + + + + + + + + + + + + + + + + Buy + + + + + + + + + + + + + + Swap + + + + + + + + + + + + + + Bridge + + + + + + + + + + + + + + Send + + + + + + + + + + + + + + Receive + + + + + + Your balance + + + + + + + + + + + + + + + + + + Ethereum + + + + 0 ETH + + + + + + + +`; diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index 0ce39e4eb0a..8c8d941b7bb 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -1361,6 +1361,7 @@ export function getNetworkNavbarOptions( onRightPress = undefined, disableNetwork = false, contentOffset = 0, + networkName = '', ) { const innerStyles = StyleSheet.create({ headerStyle: { @@ -1385,6 +1386,7 @@ export function getNetworkNavbarOptions( disableNetwork={disableNetwork} title={title} translate={translate} + networkName={networkName} /> ), headerLeft: () => ( @@ -1738,9 +1740,16 @@ export function getSwapsQuotesNavbar(navigation, route, themeColors) { MetaMetricsEvents.QUOTES_REQUEST_CANCELLED, ) .addProperties({ - ...trade, + token_from: trade.token_from, + token_to: trade.token_to, + request_type: trade.request_type, + custom_slippage: trade.custom_slippage, + chain_id: trade.chain_id, responseTime: new Date().getTime() - quoteBegin, }) + .addSensitiveProperties({ + token_from_amount: trade.token_from_amount, + }) .build(), ); } @@ -1757,9 +1766,16 @@ export function getSwapsQuotesNavbar(navigation, route, themeColors) { MetaMetricsEvents.QUOTES_REQUEST_CANCELLED, ) .addProperties({ - ...trade, + token_from: trade.token_from, + token_to: trade.token_to, + request_type: trade.request_type, + custom_slippage: trade.custom_slippage, + chain_id: trade.chain_id, responseTime: new Date().getTime() - quoteBegin, }) + .addSensitiveProperties({ + token_from_amount: trade.token_from_amount, + }) .build(), ); } diff --git a/app/components/UI/NavbarTitle/index.js b/app/components/UI/NavbarTitle/index.js index 7cc2846d099..d1ce5408209 100644 --- a/app/components/UI/NavbarTitle/index.js +++ b/app/components/UI/NavbarTitle/index.js @@ -6,7 +6,6 @@ import { TouchableOpacity, View, StyleSheet } from 'react-native'; import { fontStyles, colors as importedColors } from '../../../styles/common'; import Networks, { getDecimalChainId } from '../../../util/networks'; import { strings } from '../../../../locales/i18n'; -import Device from '../../../util/device'; import { ThemeContext, mockTheme } from '../../../util/theme'; import Routes from '../../../constants/navigation/Routes'; import { MetaMetricsEvents } from '../../../core/Analytics'; diff --git a/app/components/UI/NetworkCell/NetworkCell.tsx b/app/components/UI/NetworkCell/NetworkCell.tsx index 58ea3d26c5d..a9816e6c86a 100644 --- a/app/components/UI/NetworkCell/NetworkCell.tsx +++ b/app/components/UI/NetworkCell/NetworkCell.tsx @@ -1,23 +1,21 @@ import React from 'react'; import { Switch, ImageSourcePropType } from 'react-native'; -import { ETHERSCAN_SUPPORTED_NETWORKS } from '@metamask/transaction-controller'; import { useStyles } from '../../../component-library/hooks'; import Cell from '../../../component-library/components/Cells/Cell/Cell'; import { CellVariant } from '../../../component-library/components/Cells/Cell'; import { AvatarVariant } from '../../../component-library/components/Avatars/Avatar/Avatar.types'; import { useTheme } from '../../../util/theme'; -import { EtherscanSupportedHexChainId } from '@metamask/preferences-controller'; import styleSheet from './NetworkCell.styles'; +import { Hex } from '@metamask/utils'; -const supportedNetworks = ETHERSCAN_SUPPORTED_NETWORKS; interface NetworkCellProps { name: string; - chainId: EtherscanSupportedHexChainId | keyof typeof supportedNetworks; + chainId: Hex; imageSource: ImageSourcePropType; - secondaryText: string; + secondaryText?: string; showIncomingTransactionsNetworks: Record; toggleEnableIncomingTransactions: ( - chainId: EtherscanSupportedHexChainId, + chainId: Hex, value: boolean, ) => void; testID?: string; diff --git a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap index fa6b7d2bf5b..fb2c30e33a1 100644 --- a/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap +++ b/app/components/UI/NetworkModal/__snapshots__/index.test.tsx.snap @@ -237,9 +237,8 @@ exports[`NetworkDetails renders correctly 1`] = ` style={ { "alignItems": "center", - "backgroundColor": "#f2f4f6", + "backgroundColor": "#ffffff", "borderRadius": 8, - "borderWidth": 1, "height": 16, "justifyContent": "center", "overflow": "hidden", @@ -248,21 +247,24 @@ exports[`NetworkDetails renders correctly 1`] = ` } testID="network-avatar-picker" > - - T - + testID="network-avatar-image" + /> ({ + context: { + PreferencesController: { + setTokenNetworkFilter: jest.fn(), + }, + NetworkController: { + updateNetwork: jest.fn(), + addNetwork: jest.fn(), + setActiveNetwork: jest.fn(), + }, + }, +})); + interface NetworkProps { isVisible: boolean; onClose: () => void; @@ -18,27 +36,46 @@ interface NetworkProps { showPopularNetworkModal: boolean; } +const mockDispatch = jest.fn(); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), - useDispatch: jest.fn(), + useDispatch: () => mockDispatch, useSelector: jest.fn(), })); + describe('NetworkDetails', () => { const props: NetworkProps = { isVisible: true, - onClose: () => ({}), + onClose: jest.fn(), networkConfiguration: { - chainId: '1', + chainId: '0x1', nickname: 'Test Network', - ticker: 'Test', + ticker: 'TEST', rpcUrl: 'https://localhost:8545', formattedRpcUrl: 'https://localhost:8545', rpcPrefs: { blockExplorerUrl: 'https://test.com', imageUrl: 'image' }, }, - navigation: 'navigation', + navigation: { navigate: jest.fn(), goBack: jest.fn() }, shouldNetworkSwitchPopToWallet: true, showPopularNetworkModal: true, }; + + beforeEach(() => { + jest.clearAllMocks(); + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === selectNetworkName) return 'Ethereum Main Network'; + if (selector === selectUseSafeChainsListValidation) return true; + return {}; + }); + }); + + const renderWithTheme = (component: React.ReactNode) => + render( + + {component} + , + ); + it('renders correctly', () => { (useSelector as jest.MockedFn).mockImplementation( (selector) => { @@ -46,8 +83,31 @@ describe('NetworkDetails', () => { if (selector === selectUseSafeChainsListValidation) return true; }, ); - const { toJSON } = render(); + const { toJSON } = renderWithTheme(); expect(toJSON()).toMatchSnapshot(); }); + + it('should call setTokenNetworkFilter when switching networks', async () => { + const { getByTestId } = renderWithTheme(); + + const approveButton = getByTestId( + NetworkApprovalBottomSheetSelectorsIDs.APPROVE_BUTTON, + ); + fireEvent.press(approveButton); + + const switchButton = getByTestId( + NetworkAddedBottomSheetSelectorsIDs.SWITCH_NETWORK_BUTTON, + ); + await act(async () => { + fireEvent.press(switchButton); + }); + + expect( + Engine.context.PreferencesController.setTokenNetworkFilter, + ).toHaveBeenCalledWith({ + [props.networkConfiguration.chainId]: true, + }); + expect(mockDispatch).toHaveBeenCalled(); + }); }); diff --git a/app/components/UI/NetworkModal/index.tsx b/app/components/UI/NetworkModal/index.tsx index 53c37e95c45..30d7d4e1efd 100644 --- a/app/components/UI/NetworkModal/index.tsx +++ b/app/components/UI/NetworkModal/index.tsx @@ -22,7 +22,10 @@ import { import { useTheme } from '../../../util/theme'; import { networkSwitched } from '../../../actions/onboardNetwork'; import { NetworkApprovalBottomSheetSelectorsIDs } from '../../../../e2e/selectors/Network/NetworkApprovalBottomSheet.selectors'; -import { selectUseSafeChainsListValidation } from '../../../selectors/preferencesController'; +import { + selectTokenNetworkFilter, + selectUseSafeChainsListValidation, +} from '../../../selectors/preferencesController'; import BottomSheetFooter, { ButtonsAlignment, } from '../../../component-library/components/BottomSheets/BottomSheetFooter'; @@ -34,7 +37,10 @@ import { useMetrics } from '../../../components/hooks/useMetrics'; import { toHex } from '@metamask/controller-utils'; import { rpcIdentifierUtility } from '../../../components/hooks/useSafeChains'; import Logger from '../../../util/Logger'; -import { selectNetworkConfigurations } from '../../../selectors/networkController'; +import { + selectNetworkConfigurations, + selectIsAllNetworks, +} from '../../../selectors/networkController'; import { NetworkConfiguration, RpcEndpointType, @@ -85,6 +91,7 @@ const NetworkModals = (props: NetworkProps) => { const [showDetails, setShowDetails] = React.useState(false); const [networkAdded, setNetworkAdded] = React.useState(false); const [showCheckNetwork, setShowCheckNetwork] = React.useState(false); + const tokenNetworkFilter = useSelector(selectTokenNetworkFilter); const [alerts, setAlerts] = React.useState< { alertError: string; @@ -96,6 +103,7 @@ const NetworkModals = (props: NetworkProps) => { const isCustomNetwork = true; const showDetailsModal = () => setShowDetails(!showDetails); const showCheckNetworkModal = () => setShowCheckNetwork(!showCheckNetwork); + const isAllNetworks = useSelector(selectIsAllNetworks); const { colors } = useTheme(); const styles = createNetworkModalStyles(colors); @@ -107,6 +115,30 @@ const NetworkModals = (props: NetworkProps) => { return true; }; + const customNetworkInformation = { + chainId, + blockExplorerUrl, + chainName: nickname, + rpcUrl, + icon: imageUrl, + ticker, + alerts, + }; + + const onUpdateNetworkFilter = useCallback(() => { + const { PreferencesController } = Engine.context; + if (!isAllNetworks) { + PreferencesController.setTokenNetworkFilter({ + [customNetworkInformation.chainId]: true, + }); + } else { + PreferencesController.setTokenNetworkFilter({ + ...tokenNetworkFilter, + [customNetworkInformation.chainId]: true, + }); + } + }, [customNetworkInformation.chainId, isAllNetworks, tokenNetworkFilter]); + const addNetwork = async () => { const isValidUrl = validateRpcUrl(rpcUrl); if (showPopularNetworkModal) { @@ -170,16 +202,6 @@ const NetworkModals = (props: NetworkProps) => { selectNetworkConfigurations, ); - const customNetworkInformation = { - chainId, - blockExplorerUrl, - chainName: nickname, - rpcUrl, - icon: imageUrl, - ticker, - alerts, - }; - const checkNetwork = useCallback(async () => { if (useSafeChainsListValidation) { const alertsNetwork = await checkSafeNetwork( @@ -243,6 +265,7 @@ const NetworkModals = (props: NetworkProps) => { } if (networkClientId) { + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); } @@ -268,7 +291,7 @@ const NetworkModals = (props: NetworkProps) => { const { networkClientId } = updatedNetwork?.rpcEndpoints?.[updatedNetwork.defaultRpcEndpointIndex] ?? {}; - + onUpdateNetworkFilter(); await NetworkController.setActiveNetwork(networkClientId); }; @@ -337,6 +360,7 @@ const NetworkModals = (props: NetworkProps) => { addedNetwork?.rpcEndpoints?.[addedNetwork.defaultRpcEndpointIndex] ?? {}; + onUpdateNetworkFilter(); NetworkController.setActiveNetwork(networkClientId); } onClose(); diff --git a/app/components/UI/Notification/BaseNotification/index.js b/app/components/UI/Notification/BaseNotification/index.js index a8fe45ea886..8c137a39168 100644 --- a/app/components/UI/Notification/BaseNotification/index.js +++ b/app/components/UI/Notification/BaseNotification/index.js @@ -154,7 +154,7 @@ const getTitle = (status, { nonce, amount, assetType }) => { }; export const getDescription = (status, { amount = null, type = null }) => { - if (amount && typeof amount !== 'object') { + if (amount && typeof amount !== 'object' && type) { return strings(`notifications.${type}_${status}_message`, { amount }); } return strings(`notifications.${status}_message`); diff --git a/app/components/UI/PaymentRequest/index.test.tsx b/app/components/UI/PaymentRequest/index.test.tsx index 391fe7e3b89..a01c0814d2f 100644 --- a/app/components/UI/PaymentRequest/index.test.tsx +++ b/app/components/UI/PaymentRequest/index.test.tsx @@ -39,6 +39,11 @@ const initialState = { }, }, tokens: [], + allTokens: { + '0x1': { + '0xc4955c0d639d99699bfd7ec54d9fafee40e4d272': [], + }, + }, }, NetworkController: { provider: { @@ -50,7 +55,7 @@ const initialState = { ...MOCK_ACCOUNTS_CONTROLLER_STATE, internalAccounts: { ...MOCK_ACCOUNTS_CONTROLLER_STATE.internalAccounts, - selectedAccount: {}, + selectedAccount: '30786334-3935-4563-b064-363339643939', }, }, TokenListController: { diff --git a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx index 2b6d27d344b..cf998ed53a2 100644 --- a/app/components/UI/PermissionsSummary/PermissionsSummary.tsx +++ b/app/components/UI/PermissionsSummary/PermissionsSummary.tsx @@ -505,6 +505,9 @@ const PermissionsSummary = ({