diff --git a/.cursor/rules/pr-creation-guidelines.mdc b/.cursor/rules/pr-creation-guidelines.mdc index 3f3bee63469..7b026514e52 100644 --- a/.cursor/rules/pr-creation-guidelines.mdc +++ b/.cursor/rules/pr-creation-guidelines.mdc @@ -131,7 +131,7 @@ Apply labels to enable automation and proper routing. Some labels can block merg - **QA labels**: - `needs-qa` (blocks merging until QA passes) - `No QA Needed` - - `Run E2E Smoke` + - `Run Smoke E2E` - `No E2E Smoke Needed` - `Spot check on release build` - **Blocking labels** (cannot merge while applied): `needs-qa`, `issues-found`, `blocked`, `DO-NOT-MERGE` diff --git a/.detoxrc.js b/.detoxrc.js index 0cdf6bf15f3..001c22f4f66 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -50,6 +50,10 @@ module.exports = { device: 'ios.simulator', app: 'ios.flask.release', }, + 'ios.github_ci.main.release': { + device: 'ios.github_ci.simulator', + app: 'ios.debug', + }, 'android.emu.debug': { device: 'android.emulator', app: 'android.debug', @@ -74,6 +78,13 @@ module.exports = { type: 'iPhone 15 Pro', }, }, + 'ios.github_ci.simulator': { + type: 'ios.simulator', + device: { + type: 'iPhone 16 Pro', + os: 'iOS 18.6', + }, + }, 'android.bitrise.emulator': { type: 'android.emulator', device: { diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml index c851a1c3fff..fc22dd76b25 100644 --- a/.github/actionlint.yaml +++ b/.github/actionlint.yaml @@ -5,6 +5,9 @@ self-hosted-runner: - "gha-mmsdk-scale-set-ubuntu-22.04-amd64-large" - "gha-mm-scale-set-ubuntu-22.04-amd64-large" - "macos-15" + - "ghcr.io/cirruslabs/macos-runner:sequoia" + - "ghcr.io/cirruslabs/macos-runner:sequoia-xl" + - "low-priority" # Configuration variables in array of strings defined in your repository or # organization. `null` means disabling configuration variables check. diff --git a/.github/guidelines/LABELING_GUIDELINES.md b/.github/guidelines/LABELING_GUIDELINES.md index 1a1036e30c7..5daa9d2e179 100644 --- a/.github/guidelines/LABELING_GUIDELINES.md +++ b/.github/guidelines/LABELING_GUIDELINES.md @@ -1,7 +1,9 @@ # PR Labeling Guidelines + To maintain a consistent and efficient development workflow, we have set specific label guidelines for all pull requests (PRs). Please ensure you adhere to the following instructions: ### Mandatory team labels: + - **Internal Developers**: Every PR raised by an internal developer must include a label prefixed with `team-` (e.g., `team-mobile-ux`, `team-mobile-platform`, etc.). This indicates the respective internal team responsible for the PR. - **External Contributors**: PRs submitted by contributors who are not part of the organization will be automatically labeled with `external-contributor`. @@ -9,27 +11,33 @@ To maintain a consistent and efficient development workflow, we have set specifi It's essential to ensure that PRs have the appropriate labels before they are considered for merging. ### Mandatory release version labels: + - **release-x.y.z**: This label is automatically added to a PR and its linked issues upon the PR's merge. The `x.y.z` in the label represents the version in which the changes from the PR will be included. This label is auto-generated by a [GitHub action](../workflows/add-release-label.yml), which determines the version by incrementing the minor version number from the most recent release. Manual intervention is only required in specific cases. For instance, if a merged PR is cherry-picked into a release branch, typically done to address Release Candidate (RC) bugs, the label would need to be manually updated to reflect the correct version. - **regression-prod-x.y.z**: This label is automatically added to a bug report issue at the time of its creation. The `x.y.z` in the label represents the version in which the bug first appeared. This label is auto-generated by a [GitHub action](../workflows/check-template-and-add-labels.yml), which determines the `x.y.z` value based on the version information provided in the bug report issue form. Manual intervention is only necessary under certain circumstances. For example, if a user submits a bug report and specifies the version they are currently using, but the bug was actually introduced in a prior version, the label would need to be manually updated to accurately reflect the version where the bug originated. - **regression-RC-x.y.z**: This label is manually added to a bug report issue by release engineers when a bug is found during release regerssion testing phase. The `x.y.z` in the label represents the release candidate (RC) version in which the bug's been discovered. ### Mandatory QA labels: + Every PR shall include one the QA labels below: + - **needs-qa**: If the PR includes a new features, complex testing steps, or large refactors, this label must be added to indicated PR requires a full manual QA prior being merged and added to a release. - **Spot check on release build**: If PR does not require feature QA but needs non-automated verification, this label must be added. Furthermore, when that label is added, you must provide test scenarios in the description section, as well as add screenshots, and or recordings of what was tested. To merge your PR one of the following QA labels are required: + - **QA Passed**: If the PR was labeled with `needs-qa`, this label must be added once QA has signed off - **No QA Needed**: If the PR does not require any QA effort. This label should only be used in case you are updating a README or other files that does not impact the building or runtime of the application. -- **Run E2E Smoke**: This label will kick-off E2E testing and trigger a check to make sure the E2E tests pass. +- **Run Smoke E2E**: This label will kick-off E2E testing and trigger a check to make sure the E2E tests pass. - **No E2E Smoke Needed**: This label will bypass the E2E smoke test gate and allow the PR to be merged. ### Optional labels: + - **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: - **needs-qa**: The PR requires a full manual QA prior to being merged and added to a release. diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index c081690aa5d..8dfee7c3792 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -20,6 +20,8 @@ jobs: build-android-apks: name: Build Android E2E APKs runs-on: gha-mmsdk-scale-set-ubuntu-22.04-amd64-xl + env: + GRADLE_USER_HOME: /home/runner/_work/.gradle outputs: apk-uploaded: ${{ steps.upload-apk.outcome == 'success' }} aab-uploaded: ${{ steps.upload-aab.outcome == 'success' }} @@ -41,8 +43,8 @@ jobs: uses: actions/cache@v4 with: path: | - ~/.gradle/caches - ~/.gradle/wrapper + /home/runner/_work/.gradle/caches + /home/runner/_work/.gradle/wrapper android/.gradle key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} restore-keys: | diff --git a/.github/workflows/build-ios-e2e.yml b/.github/workflows/build-ios-e2e.yml new file mode 100644 index 00000000000..4a32b4b2aed --- /dev/null +++ b/.github/workflows/build-ios-e2e.yml @@ -0,0 +1,167 @@ +name: Build iOS E2E Apps + +on: + workflow_call: + +permissions: + contents: read + id-token: write + +jobs: + build-ios-apps: + name: Build iOS E2E Apps + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl + outputs: + artifacts-url: ${{ steps.set-artifacts-url.outputs.artifacts-url }} + app-uploaded: ${{ steps.upload-app.outcome == 'success' }} + sourcemap-uploaded: ${{ steps.upload-sourcemap.outcome == 'success' }} + env: + RCT_NO_LAUNCH_PACKAGER: 1 + XCODE_BUILD_SETTINGS: "COMPILER_INDEX_STORE_ENABLE=NO" + GITHUB_CI: "true" # This ensures it's available during pod install + PLATFORM: ios + METAMASK_ENVIRONMENT: qa + METAMASK_BUILD_TYPE: main + IS_TEST: true + E2E: "true" + IGNORE_BOXLOGS_DEVELOPMENT: true + CI: "true" + NODE_OPTIONS: "--max-old-space-size=8192" + MM_UNIFIED_SWAPS_ENABLED: "true" + MM_BRIDGE_ENABLED: "true" + BRIDGE_USE_DEV_APIS: "true" + RAMP_INTERNAL_BUILD: "true" + SEEDLESS_ONBOARDING_ENABLED: "true" + MM_NOTIFICATIONS_UI_ENABLED: "true" + MM_SECURITY_ALERTS_API_ENABLED: "true" + FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN: ${{ secrets.FEATURES_ANNOUNCEMENTS_ACCESS_TOKEN }} + FEATURES_ANNOUNCEMENTS_SPACE_ID: ${{ secrets.FEATURES_ANNOUNCEMENTS_SPACE_ID }} + SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} + SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} + SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} + SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} + MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} + MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} + MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} + MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} + GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} + MM_INFURA_PROJECT_ID: ${{ secrets.MM_INFURA_PROJECT_ID }} + + steps: + # Get the source code from the repository + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Cache Xcode derived data + uses: actions/cache@v4 + with: + path: | + ~/Library/Developer/Xcode/DerivedData + ios/build + key: ${{ runner.os }}-xcode-${{ hashFiles('ios/**/*.{h,m,mm,swift}', 'ios/**/Podfile.lock', 'yarn.lock') }} + restore-keys: | + ${{ runner.os }}-xcode- + + # Install Node.js, Xcode tools, and other iOS development dependencies + - name: Installing iOS Environment Setup + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@self-hosted-runners-config + with: + platform: ios + setup-simulator: false + + - name: Print iOS tool versions + run: | + echo "πŸ”§ Node.js Version:" + node -v || echo "Node not found" + echo "🧢 Yarn Version:" + yarn -v || echo "Yarn not found" + echo "πŸ“¦ CocoaPods Version:" + pod --version || echo "CocoaPods not found" + echo "πŸ› οΈ Xcode Path:" + xcode-select -p || echo "Xcode not found" + echo "πŸ“± Booted iOS Simulators:" + xcrun simctl list | grep Booted || echo "No booted simulators found" + shell: bash + + # Clean iOS plist files to prevent extended attribute issues + - name: Clean iOS plist files + run: find ios -name "*.plist" -exec xattr -c {} \; + + # Run project setup and build the iOS E2E app for simulator + - name: Build iOS E2E App + run: | + echo "πŸš€ Setting up project..." + yarn setup:github-ci --build-ios --no-build-android + echo "πŸ— Building iOS E2E App..." + export NODE_OPTIONS="--max-old-space-size=8192" + yarn build:ios:main:e2e + shell: bash + env: + PLATFORM: ios + METAMASK_ENVIRONMENT: main + METAMASK_BUILD_TYPE: main + IS_TEST: true + IS_SIM_BUILD: "true" # Ensures simulator build (.app) not device build (.ipa) + IGNORE_BOXLOGS_DEVELOPMENT: true + GITHUB_CI: "true" + CI: "true" + + NODE_OPTIONS: "--max_old_space_size=4096" # Increase memory limit for build + + SEGMENT_WRITE_KEY_QA: ${{ secrets.SEGMENT_WRITE_KEY_QA }} + SEGMENT_PROXY_URL_QA: ${{ secrets.SEGMENT_PROXY_URL_QA }} + SEGMENT_DELETE_API_SOURCE_ID_QA: ${{ secrets.SEGMENT_DELETE_API_SOURCE_ID_QA }} + SEGMENT_REGULATIONS_ENDPOINT_QA: ${{ secrets.SEGMENT_REGULATIONS_ENDPOINT_QA }} + + MM_SENTRY_DSN_TEST: ${{ secrets.MM_SENTRY_DSN_TEST }} + MM_SENTRY_AUTH_TOKEN: ${{ secrets.MM_SENTRY_AUTH_TOKEN }} + + MAIN_IOS_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_IOS_GOOGLE_CLIENT_ID_UAT }} + MAIN_IOS_GOOGLE_REDIRECT_URI_UAT: ${{ secrets.MAIN_IOS_GOOGLE_REDIRECT_URI_UAT }} + MAIN_ANDROID_APPLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_APPLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT }} + MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT: ${{ secrets.MAIN_ANDROID_GOOGLE_SERVER_CLIENT_ID_UAT }} + GOOGLE_SERVICES_B64_IOS: ${{ secrets.GOOGLE_SERVICES_B64_IOS }} + GOOGLE_SERVICES_B64_ANDROID: ${{ secrets.GOOGLE_SERVICES_B64_ANDROID }} + + # Upload the iOS .app file that works in simulators + - name: Upload iOS APP Artifact (Simulator) + id: upload-app + uses: actions/upload-artifact@v4 + with: + name: MetaMask.app + path: ios/build/Build/Products/Release-iphonesimulator/MetaMask.app + retention-days: 7 + if-no-files-found: error + continue-on-error: true + + # Upload source map file for crash debugging and error tracking if exists + - name: Upload iOS Source Map + id: upload-sourcemap + uses: actions/upload-artifact@v4 + with: + name: index.js.map + path: sourcemaps/ios/index.js.map + retention-days: 7 + if-no-files-found: error + continue-on-error: true + + # Generate artifact download URL and display upload status summary + - name: Set Artifacts URL and Status + id: set-artifacts-url + run: | + ARTIFACTS_URL="https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "artifacts-url=${ARTIFACTS_URL}" >> "$GITHUB_OUTPUT" + echo "πŸ“¦ Artifacts available at: ${ARTIFACTS_URL}" + echo "" + echo "Upload Status Summary:" + echo "- APP (Simulator): ${{ steps.upload-app.outcome }}" + echo "- Source Map: ${{ steps.upload-sourcemap.outcome }}" + + env: + GITHUB_REPOSITORY: "${{ github.repository }}" + GITHUB_RUN_ID: "${{ github.run_id }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cadc4474c6d..5f5a969973b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,7 +1,7 @@ name: ci on: push: - branches: main + branches: [main] pull_request: merge_group: types: [checks_requested] @@ -175,6 +175,15 @@ jobs: uses: ./.github/workflows/build-android-e2e.yml secrets: inherit + build-ios-apps: + name: "Build iOS Apps" + if: ${{ github.event_name != 'merge_group' }} + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-ios-e2e.yml + secrets: inherit + e2e-smoke-tests-android: name: "Android E2E Smoke Tests" permissions: @@ -184,6 +193,15 @@ jobs: uses: ./.github/workflows/run-e2e-smoke-tests-android.yml secrets: inherit + e2e-smoke-tests-ios: + name: "iOS E2E Smoke Tests" + permissions: + contents: read + id-token: write + needs: [build-ios-apps] + uses: ./.github/workflows/run-e2e-smoke-tests-ios.yml + secrets: inherit + js-bundle-size-check: runs-on: ubuntu-latest steps: @@ -348,6 +366,7 @@ jobs: js-bundle-size-check, sonar-cloud-quality-gate-status, e2e-smoke-tests-android, + e2e-smoke-tests-ios, ] outputs: ALL_JOBS_PASSED: ${{ steps.jobs-passed-status.outputs.ALL_JOBS_PASSED }} diff --git a/.github/workflows/run-e2e-smoke-tests-ios.yml b/.github/workflows/run-e2e-smoke-tests-ios.yml index b899b79c936..56978342fc8 100644 --- a/.github/workflows/run-e2e-smoke-tests-ios.yml +++ b/.github/workflows/run-e2e-smoke-tests-ios.yml @@ -9,67 +9,115 @@ permissions: jobs: confirmations-ios-smoke: + strategy: + matrix: + split: [1, 2, 3] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: confirmations-ios-smoke + test-suite-name: confirmations-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeConfirmations" + split_number: ${{ matrix.split }} + total_splits: 3 secrets: inherit - confirmations-redesigned-ios-smoke: - uses: ./.github/workflows/run-e2e-workflow.yml - with: - test-suite-name: confirmations-redesigned-ios-smoke - platform: ios - test_suite_tag: "SmokeConfirmationsRedesigned" - secrets: inherit + #confirmations-redesigned-ios-smoke: + # strategy: + # matrix: + # split: [1, 2] + # fail-fast: false + # uses: ./.github/workflows/run-e2e-workflow.yml + # with: + # test-suite-name: confirmations-redesigned-ios-smoke-${{ matrix.split }} + # platform: ios + # test_suite_tag: "SmokeConfirmationsRedesigned" + # split_number: ${{ matrix.split }} + # total_splits: 2 + # secrets: inherit trade-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: trade-ios-smoke + test-suite-name: trade-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeTrade" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit wallet-platform-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: wallet-platform-ios-smoke + test-suite-name: wallet-platform-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeWalletPlatform" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit identity-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: identity-ios-smoke + test-suite-name: identity-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeIdentity" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit accounts-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: accounts-ios-smoke + test-suite-name: accounts-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeAccounts" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit network-abstraction-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: network-abstraction-ios-smoke + test-suite-name: network-abstraction-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeNetworkAbstractions" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit network-expansion-ios-smoke: + strategy: + matrix: + split: [1, 2] + fail-fast: false uses: ./.github/workflows/run-e2e-workflow.yml with: - test-suite-name: network-expansion-ios-smoke + test-suite-name: network-expansion-ios-smoke-${{ matrix.split }} platform: ios test_suite_tag: "SmokeNetworkExpansion" + split_number: ${{ matrix.split }} + total_splits: 2 secrets: inherit # performance-ios-smoke: @@ -94,7 +142,7 @@ jobs: if: ${{ !cancelled() }} needs: - confirmations-ios-smoke - - confirmations-redesigned-ios-smoke + #- confirmations-redesigned-ios-smoke - trade-ios-smoke - wallet-platform-ios-smoke - identity-ios-smoke diff --git a/.github/workflows/run-e2e-workflow.yml b/.github/workflows/run-e2e-workflow.yml index a97cf93360b..1e8c9c0dfdd 100644 --- a/.github/workflows/run-e2e-workflow.yml +++ b/.github/workflows/run-e2e-workflow.yml @@ -37,12 +37,13 @@ on: jobs: test-e2e-mobile: name: ${{ inputs.test-suite-name }} - runs-on: ${{ inputs.platform == 'ios' && 'macos-latest-xlarge' || 'gha-mmsdk-scale-set-ubuntu-22.04-amd64-xl' }} + runs-on: ${{ inputs.platform == 'ios' && fromJSON('["ghcr.io/cirruslabs/macos-runner:sequoia", "low-priority"]') || 'gha-mmsdk-scale-set-ubuntu-22.04-amd64-xl' }} concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.platform }}-${{ inputs.test-suite-name }}-${{ inputs.split_number }} cancel-in-progress: ${{ !(contains(github.ref, 'refs/heads/main') || contains(github.ref, 'refs/heads/stable')) }} env: + PREBUILT_IOS_APP_PATH: artifacts/MetaMask.app METAMASK_ENVIRONMENT: 'qa' METAMASK_BUILD_TYPE: 'main' TEST_SUITE_TAG: ${{ inputs.test_suite_tag }} @@ -139,6 +140,19 @@ jobs: exit 1 fi + - name: Setup iOS artifacts from build job + if: ${{ inputs.platform == 'ios' }} + run: | + echo "πŸ— Setting up iOS artifacts from build job..." + + # Create required directories + mkdir -p ios/build/Build/Products/Release-iphonesimulator/ + + - name: Download iOS build artifacts + if: ${{ inputs.platform == 'ios' }} + uses: actions/download-artifact@v4 + with: + path: artifacts/ - name: Clean environment before tests (iOS only) if: ${{ inputs.platform == 'ios' }} run: | diff --git a/.github/workflows/test-ios-build-app.yml b/.github/workflows/test-ios-build-app.yml index e770691f594..be1e1023759 100644 --- a/.github/workflows/test-ios-build-app.yml +++ b/.github/workflows/test-ios-build-app.yml @@ -16,7 +16,7 @@ permissions: jobs: ios-build: name: Test iOS QA Build App - runs-on: macos-15 + runs-on: ghcr.io/cirruslabs/macos-runner:sequoia outputs: artifacts-url: ${{ steps.set-artifacts-url.outputs.artifacts-url }} app-uploaded: ${{ steps.upload-app.outcome == 'success' }} @@ -52,7 +52,7 @@ jobs: # Install Node.js, Xcode tools, and other iOS development dependencies - name: Installing iOS Environment Setup - uses: MetaMask/github-tools/.github/actions/setup-e2e-env@e2e-env-actions + uses: MetaMask/github-tools/.github/actions/setup-e2e-env@self-hosted-runners-config with: platform: ios setup-simulator: false diff --git a/app/__mocks__/expo-haptics.js b/app/__mocks__/expo-haptics.js new file mode 100644 index 00000000000..e157c169597 --- /dev/null +++ b/app/__mocks__/expo-haptics.js @@ -0,0 +1,51 @@ +// mock expo-haptics for testing + +export const impactAsync = jest.fn().mockResolvedValue(undefined); +export const notificationAsync = jest.fn().mockResolvedValue(undefined); +export const selectionAsync = jest.fn().mockResolvedValue(undefined); + +export const ImpactFeedbackStyle = { + Light: 'light', + Medium: 'medium', + Heavy: 'heavy', + Rigid: 'rigid', + Soft: 'soft', +}; + +export const NotificationFeedbackType = { + Success: 'success', + Warning: 'warning', + Error: 'error', +}; + +export const AndroidHaptics = { + Clock_Tick: 'clock-tick', + Confirm: 'confirm', + Context_Click: 'context-click', + Drag_Start: 'drag-start', + Gesture_End: 'gesture-end', + Gesture_Start: 'gesture-start', + Keyboard_Press: 'keyboard-press', + Keyboard_Release: 'keyboard-release', + Keyboard_Tap: 'keyboard-tap', + Long_Press: 'long-press', + No_Haptics: 'no-haptics', + Reject: 'reject', + Segment_Frequent_Tick: 'segment-frequent-tick', + Segment_Tick: 'segment-tick', + Text_Handle_Move: 'text-handle-move', + Toggle_Off: 'toggle-off', + Toggle_On: 'toggle-on', + Virtual_Key: 'virtual-key', + Virtual_Key_Release: 'virtual-key-release', +}; + +// Default export for namespace imports +export default { + impactAsync, + notificationAsync, + selectionAsync, + ImpactFeedbackStyle, + NotificationFeedbackType, + AndroidHaptics, +}; diff --git a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx index 78d677fbcc1..e9644a05c6a 100644 --- a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx +++ b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.test.tsx @@ -103,8 +103,14 @@ describe('AccountCell', () => { const { getByTestId } = renderAccountCell(); const menuButton = getByTestId('multichain-account-cell-menu'); fireEvent.press(menuButton); - expect(mockNavigate).toHaveBeenCalledWith('MultichainAccountActions', { - accountGroup: mockAccountGroup, - }); + expect(mockNavigate).toHaveBeenCalledWith( + 'MultichainAccountDetailActions', + { + screen: 'MultichainAccountActions', + params: { + accountGroup: mockAccountGroup, + }, + }, + ); }); }); diff --git a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx index 78a914e8bef..eb76b2be787 100644 --- a/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx +++ b/app/component-library/components-temp/MultichainAccounts/AccountCell/AccountCell.tsx @@ -34,8 +34,9 @@ const AccountCell = ({ const { navigate } = useNavigation(); const handleMenuPress = useCallback(() => { - navigate(Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_CELL_ACTIONS, { - accountGroup, + navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { + screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.ACCOUNT_ACTIONS, + params: { accountGroup }, }); }, [navigate, accountGroup]); diff --git a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.styles.ts b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.styles.ts index 933a2d0ff4c..7c4b40748e5 100644 --- a/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.styles.ts +++ b/app/component-library/components-temp/MultichainAccounts/MultichainAccountSelectorList/AccountListFooter/AccountListFooter.styles.ts @@ -22,9 +22,9 @@ const createStyles = ({ theme }: { theme: Theme }) => backgroundColor: theme.colors.background.muted, borderRadius: 8, padding: 8, - marginRight: 12, - width: 36, - height: 36, + marginRight: 16, + width: 32, + height: 32, alignItems: 'center', justifyContent: 'center', }, diff --git a/app/components/Nav/App/App.test.tsx b/app/components/Nav/App/App.test.tsx index 35ca7b670a3..9721e10a8f5 100644 --- a/app/components/Nav/App/App.test.tsx +++ b/app/components/Nav/App/App.test.tsx @@ -716,7 +716,7 @@ describe('App', () => { const { getByText } = renderAppWithRouteState(routeState); await waitFor(() => { - expect(getByText('Edit Account Name')).toBeTruthy(); + expect(getByText('Account Group')).toBeTruthy(); }); }); diff --git a/app/components/Nav/App/App.tsx b/app/components/Nav/App/App.tsx index ca6643eb3f5..86ce81beba4 100644 --- a/app/components/Nav/App/App.tsx +++ b/app/components/Nav/App/App.tsx @@ -75,7 +75,8 @@ import FundActionMenu from '../../UI/FundActionMenu'; import NetworkSelector from '../../../components/Views/NetworkSelector'; import ReturnToAppModal from '../../Views/ReturnToAppModal'; import EditAccountName from '../../Views/EditAccountName/EditAccountName'; -import MultichainEditAccountName from '../../Views/MultichainAccounts/sheets/EditAccountName'; +import LegacyEditMultichainAccountName from '../../Views/MultichainAccounts/sheets/EditAccountName'; +import { EditMultichainAccountName } from '../../Views/MultichainAccounts/sheets/EditMultichainAccountName'; import { PPOMView } from '../../../lib/ppom/PPOMView'; import LockScreen from '../../Views/LockScreen'; import StorageWrapper from '../../../store/storage-wrapper'; @@ -669,9 +670,21 @@ const MultichainAccountDetailsActions = () => { animationEnabled: false, }} > + + diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 6f09d5ca183..86e3e1cadbf 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -1,5 +1,5 @@ import '../../_mocks_/initialState'; -import { fireEvent } from '@testing-library/react-native'; +import { fireEvent, waitFor } from '@testing-library/react-native'; import { renderScreen } from '../../../../../util/test/renderWithProvider'; import QuoteDetailsCard from './QuoteDetailsCard'; import { strings } from '../../../../../../locales/i18n'; @@ -7,6 +7,12 @@ import Routes from '../../../../../constants/navigation/Routes'; import mockQuotes from '../../_mocks_/mock-quotes-sol-sol.json'; import mockQuotesGasIncluded from '../../_mocks_/mock-quotes-gas-included.json'; import { createBridgeTestState } from '../../testUtils'; +import { useBridgeQuoteData } from '../../hooks/useBridgeQuoteData'; + +jest.mock( + '../../../../../images/metamask-rewards-points.svg', + () => 'MetamaskRewardsPointsSvg', +); const mockNavigate = jest.fn(); jest.mock('@react-navigation/native', () => ({ @@ -44,6 +50,13 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({ })), })); +// Mock Engine for rewards functionality +jest.mock('../../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + // Mock the bridge selectors jest.mock('../../../../../core/redux/slices/bridge', () => ({ ...jest.requireActual('../../../../../core/redux/slices/bridge'), @@ -245,7 +258,7 @@ describe('QuoteDetailsCard', () => { const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); const originalImpl = mockModule.useBridgeQuoteData.getMockImplementation(); - mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + mockModule.useBridgeQuoteData.mockImplementation(() => ({ quoteFetchError: null, activeQuote: mockQuotesGasIncluded[0], destTokenAmount: '24.44', @@ -268,7 +281,7 @@ describe('QuoteDetailsCard', () => { ); // Verify "Included" text is displayed - expect(getByText('Included')).toBeDefined(); + expect(getByText(strings('bridge.included'))).toBeDefined(); // Restore original implementation mockModule.useBridgeQuoteData.mockImplementation(originalImpl); @@ -449,7 +462,7 @@ describe('QuoteDetailsCard', () => { it('does not show fee disclaimer when there is no fee', () => { // Given a quote with zero fee const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); - mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + mockModule.useBridgeQuoteData.mockImplementation(() => ({ quoteFetchError: null, activeQuote: { ...mockQuotes[0], @@ -487,7 +500,7 @@ describe('QuoteDetailsCard', () => { it('shows fee disclaimer when there is a fee', () => { // Given a quote with a non-zero fee const mockModule = jest.requireMock('../../hooks/useBridgeQuoteData'); - mockModule.useBridgeQuoteData.mockImplementationOnce(() => ({ + mockModule.useBridgeQuoteData.mockImplementation(() => ({ quoteFetchError: null, activeQuote: { ...mockQuotes[0], @@ -521,4 +534,470 @@ describe('QuoteDetailsCard', () => { // Then the fee disclaimer should be displayed expect(getByText(strings('bridge.fee_disclaimer'))).toBeOnTheScreen(); }); + + describe('rewards functionality', () => { + const mockEngine = jest.requireMock('../../../../../core/Engine'); + + beforeEach(() => { + // Reset Engine mocks + jest.clearAllMocks(); + // Default to rewards disabled + mockEngine.controllerMessenger.call.mockImplementation(() => + Promise.resolve(false), + ); + }); + + it('displays rewards row when rewards are enabled and user has opted in', async () => { + // Given rewards feature is enabled and user has opted in + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + // Note: In the actual implementation, these are commented out as TODO + // But we'll mock them as if they were working + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 100 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, getByText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion to see rewards + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should be displayed + await waitFor(() => { + expect(getByText(strings('bridge.points'))).toBeOnTheScreen(); + }); + }); + + it('displays rewards row without points when estimation fails', async () => { + // Given rewards estimation fails but feature is enabled and user has opted in + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + // Throw error to simulate failure + throw new Error('Estimation failed'); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should be shown but without points value + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + + // But no numeric value should be displayed + expect(queryByText(/^\d+$/)).toBeNull(); + }); + + it('does not display rewards row when rewards feature is disabled', async () => { + // Given rewards feature is disabled + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(false); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should not be displayed + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeNull(); + }); + }); + + it('does not display rewards row when user has not opted in', async () => { + // Given rewards feature is enabled but user has not opted in + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(false); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should not be displayed + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeNull(); + }); + }); + + it('displays rewards image when rewards row is shown', async () => { + // Given rewards should be shown + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 150 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the MetaMask rewards points image should be displayed + await waitFor(() => { + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + }); + + it('does not display points value when rewards are loading', async () => { + // Given rewards are being estimated (pending promise) + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + // Return a pending promise to simulate loading + return new Promise(() => { + // Never resolves to simulate loading state + }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should be shown but without points value + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + // Points value should not be displayed while loading + expect(queryByText(/^\d+$/)).toBeNull(); + }); + + it('displays rewards row but no points when engine returns zero', async () => { + // Given rewards estimation returns zero with feature enabled and user opted in + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 0 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should be shown + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + + // When points are 0, we may show "0" or no value at all + // This behavior will depend on how useRewards handles the response + }); + + it('displays rewards tooltip when rewards row is shown', async () => { + // Given rewards should be shown + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 100 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards tooltip should be available + await waitFor(() => { + const rewardsTooltip = getByLabelText(/Points tooltip/i); + expect(rewardsTooltip).toBeOnTheScreen(); + }); + }); + + it('displays rewards row when all conditions are met', async () => { + // Given rewards feature is enabled, user has opted in, and estimation succeeds + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 500 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then the rewards row should be displayed + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + }); + + it('handles rewards estimation with null estimatedPoints', async () => { + // Given rewards with null estimated points + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: null }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText, UNSAFE_getByProps } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Then rewards row should be shown but without points value + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + expect( + UNSAFE_getByProps({ name: 'MetamaskRewardsPoints' }), + ).toBeOnTheScreen(); + }); + // No numeric value should be displayed + expect(queryByText(/^\d+$/)).toBeNull(); + }); + + it('does not show rewards in collapsed state', async () => { + // Given rewards should be shown + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 100 }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component without expanding + const { queryByText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Then rewards should not be visible in collapsed state + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeNull(); + }); + }); + + it('handles quote loading state with rewards', async () => { + // Given quote is loading + (useBridgeQuoteData as jest.Mock).mockImplementationOnce(() => ({ + quoteFetchError: null, + activeQuote: mockQuotes[0], + destTokenAmount: '24.44', + isLoading: true, + formattedQuoteData: { + networkFee: '0.01', + estimatedTime: '1 min', + rate: '1 ETH = 24.4 USDC', + priceImpact: '-0.06%', + slippage: '0.5%', + }, + })); + + // Mock Engine to simulate rewards loading + mockEngine.controllerMessenger.call.mockImplementation( + (method: string) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + // Return a pending promise to simulate loading + return new Promise(() => { + // Never resolves to simulate loading state + }); + } + return Promise.resolve(null); + }, + ); + + // When rendering the component + const { getByLabelText, queryByText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Expand the accordion + const expandButton = getByLabelText('Expand quote details'); + fireEvent.press(expandButton); + + // Component should still render without crashing + expect(expandButton).toBeOnTheScreen(); + + // Rewards row should be shown + await waitFor(() => { + expect(queryByText(strings('bridge.points'))).toBeOnTheScreen(); + }); + + // But no points value should be displayed + expect(queryByText(/^\d+$/)).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 5b7b519acaa..0af758cd48d 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -48,7 +48,9 @@ import { selectIsEvmSolanaBridge, selectBridgeFeatureFlags, } from '../../../../../core/redux/slices/bridge'; +import { useRewards } from '../../hooks/useRewards'; import BigNumber from 'bignumber.js'; +import MetamaskRewardsPointsImage from '../../../../../images/metamask-rewards-points.svg'; const ANIMATION_DURATION_MS = 50; @@ -101,12 +103,24 @@ const QuoteDetailsCard = () => { const [isExpanded, setIsExpanded] = useState(false); const rotationValue = useSharedValue(0); - const { formattedQuoteData, activeQuote } = useBridgeQuoteData(); + const { + formattedQuoteData, + activeQuote, + isLoading: isQuoteLoading, + } = useBridgeQuoteData(); const sourceToken = useSelector(selectSourceToken); const destToken = useSelector(selectDestToken); const sourceAmount = useSelector(selectSourceAmount); const isEvmSolanaBridge = useSelector(selectIsEvmSolanaBridge); const bridgeFeatureFlags = useSelector(selectBridgeFeatureFlags); + const { + estimatedPoints, + isLoading: isRewardsLoading, + shouldShowRewardsRow, + } = useRewards({ + activeQuote, + isQuoteLoading, + }); const isSameChainId = sourceToken?.chainId === destToken?.chainId; // Initialize expanded state based on whether destination is Solana or it's a Solana swap @@ -413,6 +427,39 @@ const QuoteDetailsCard = () => { }, }} /> + + {/* Estimated Points */} + {shouldShowRewardsRow && ( + + + {!isRewardsLoading && estimatedPoints !== null && ( + + {estimatedPoints.toString()} + + )} + + ), + }} + /> + )} )} diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap index 1aa1682a8ca..d8d76283001 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/__snapshots__/QuoteDetailsCard.test.tsx.snap @@ -445,7 +445,7 @@ exports[`QuoteDetailsCard renders expanded state 1`] = ` Solana - - - Slippage - - - - - This quote offers the best return from our search. It’s based on the swap rate, including bridging fees and a 0.875% MetaMask fee, but not gas fees. Gas fees vary with network activity and transaction complexity. + This quote offers the best return from our search. It's based on the swap rate, including bridging fees and a 0.875% MetaMask fee, but not gas fees. Gas fees vary with network activity and transaction complexity. diff --git a/app/components/UI/Bridge/hooks/useRewards/index.ts b/app/components/UI/Bridge/hooks/useRewards/index.ts new file mode 100644 index 00000000000..b16bc8003c6 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useRewards/index.ts @@ -0,0 +1 @@ +export * from './useRewards'; diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts new file mode 100644 index 00000000000..1d0016a61d0 --- /dev/null +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts @@ -0,0 +1,562 @@ +import '../../_mocks_/initialState'; +import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; +import { createBridgeTestState } from '../../testUtils'; +import { useRewards } from './useRewards'; +import Engine from '../../../../../core/Engine'; +import { waitFor } from '@testing-library/react-native'; +import { CaipAssetType, Hex } from '@metamask/utils'; + +// Mock dependencies +jest.mock('../../../../../core/Engine', () => ({ + controllerMessenger: { + call: jest.fn(), + }, +})); + +// Mock useBridgeQuoteData hook +const mockActiveQuote = { + quote: { + requestId: + '0xd12f19d577efae2b92748c1abc32d8be78a5e73a99d74e16cada270a2ad99516' as Hex, + bridgeId: '1inch', + srcChainId: 1, + destChainId: 1, + aggregator: '1inch', + aggregatorType: 'AGG', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60' as CaipAssetType, + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + srcTokenAmount: '991250000000000000', + destAsset: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + assetId: + 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as CaipAssetType, + symbol: 'USDC', + decimals: 6, + name: 'USDC', + coingeckoId: 'usd-coin', + aggregators: [ + 'uniswapLabs', + 'metamask', + 'aave', + 'coinGecko', + 'openSwap', + 'zerion', + 'oneInch', + 'liFi', + 'xSwap', + 'socket', + 'rubic', + 'squid', + 'rango', + 'sonarwatch', + 'sushiSwap', + 'pmm', + 'bancor', + ], + occurrences: 17, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + metadata: { + storage: { + balance: 9, + approval: 10, + }, + }, + }, + destTokenAmount: '4437209427', + minDestTokenAmount: '4348465238', + walletAddress: '0xC5FE6EF47965741f6f7A4734Bf784bf3ae3f2452', + destWalletAddress: '0xC5FE6EF47965741f6f7A4734Bf784bf3ae3f2452', + feeData: { + metabridge: { + amount: '8750000000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + assetId: 'eip155:1/slip44:60' as CaipAssetType, + symbol: 'ETH', + decimals: 18, + name: 'Ethereum', + coingeckoId: 'ethereum', + aggregators: [], + occurrences: 100, + iconUrl: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/slip44/60.png', + metadata: {}, + }, + }, + }, + bridges: ['1inch'], + protocols: ['1inch'], + steps: [], + slippage: 2, + }, + trade: { + chainId: 1, + to: '0x881D40237659C251811CEC9c364ef91dC08D300C', + from: '0xC5FE6EF47965741f6f7A4734Bf784bf3ae3f2452', + value: '0xde0b6b3a7640000', + data: '0x5f575529000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000de0b6b3a764000000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000000136f6e65496e6368563646656544796e616d69630000000000000000000000000000000000000000000000000000000000000000000000000000000000000001800000000000000000000000000000000000000000000000000000000000000000000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000000dc1a09f859b200000000000000000000000000000000000000000000000000000000001033050560000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000001f161421c8e000000000000000000000000000f326e4de8f66a0bdc0970b79e0924e33c79f191500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000048a76dfc3b0000000000000000000000000000000000000000000000000000000103305056200000000000000000000000e0554a476a092703abdb3ef35c80e0d76d32939f7dcbea7c0000000000000000000000000000000000000000000000001f', + gasLimit: 266281, + }, + estimatedProcessingTimeInSeconds: 0, + sentAmount: { + amount: '1', + valueInCurrency: '4470.66', + usd: '4470.66', + }, + minToTokenAmount: { + amount: '4348.465238', + valueInCurrency: '4348.465238', + usd: '4348.465238', + }, + toTokenAmount: { + amount: '4437.209427', + valueInCurrency: '4436.36635720887', + usd: '4436.36635720887', + }, + swapRate: '4437.209427', + totalNetworkFee: { + amount: '0.000436147290796704', + valueInCurrency: '1.94986624707319270464', + usd: '1.94986624707319270464', + }, + totalMaxNetworkFee: { + amount: '0.000908611296073614', + valueInCurrency: '4.06209217690446316524', + usd: '4.06209217690446316524', + }, + gasFee: { + effective: { + amount: '0.000436147290796704', + valueInCurrency: '1.94986624707319270464', + usd: '1.94986624707319270464', + }, + total: { + amount: '0.000436147290796704', + valueInCurrency: '1.94986624707319270464', + usd: '1.94986624707319270464', + }, + max: { + amount: '0.000908611296073614', + valueInCurrency: '4.06209217690446316524', + usd: '4.06209217690446316524', + }, + }, + adjustedReturn: { + valueInCurrency: '4434.41649096179680729536', + usd: '4434.41649096179680729536', + }, + cost: { + valueInCurrency: '36.24350903820319270464', + usd: '36.24350903820319270464', + }, + includedTxFees: null, +}; + +describe('useRewards', () => { + const mockCall = Engine.controllerMessenger.call as jest.Mock; + + const defaultSourceToken = { + address: '0x0000000000000000000000000000000000000000' as Hex, + chainId: '0x1' as Hex, + decimals: 18, + symbol: 'ETH', + currencyExchangeRate: 2000, + }; + + const defaultDestToken = { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex, + chainId: '0x1' as Hex, + decimals: 6, + symbol: 'USDC', + currencyExchangeRate: 1, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when rewards feature is disabled', () => { + it('should return default state when rewards feature is disabled', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(false); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:isRewardsFeatureEnabled', + ); + }); + }); + + describe('when user has not opted in', () => { + it('should return default state when user has not opted in', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(false); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + }); + + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:getHasAccountOptedIn', + 'eip155:1:0x1234567890123456789012345678901234567890', + ); + }); + }); + + describe('when rewards estimation is successful', () => { + it('should return estimated points when all conditions are met', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 100 }); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: true, + isLoading: false, + estimatedPoints: 100, + }); + }); + + // Verify the correct estimate request was made + expect(mockCall).toHaveBeenCalledWith( + 'RewardsController:estimatePoints', + { + activityType: 'SWAP', + account: 'eip155:1:0x1234567890123456789012345678901234567890', + activityContext: { + swapContext: { + srcAsset: { + id: 'eip155:1/slip44:60', + amount: '991250000000000000', + }, + destAsset: { + id: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + amount: '4437209427', + }, + feeAsset: { + id: 'eip155:1/slip44:60', + amount: '8750000000000000', + }, + }, + }, + }, + ); + }); + + it('should handle source token without exchange rate', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + return Promise.resolve({ pointsEstimate: 50 }); + } + return Promise.resolve(null); + }); + + const sourceTokenWithoutRate = { + ...defaultSourceToken, + currencyExchangeRate: undefined, + }; + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: sourceTokenWithoutRate, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current.estimatedPoints).toBe(50); + }); + + // Check that fee asset was created without USD price + const callArgs = mockCall.mock.calls.find( + (call) => call[0] === 'RewardsController:estimatePoints', + ); + expect(callArgs[1].activityContext.swapContext.feeAsset.usdPrice).toBe( + undefined, + ); + }); + }); + + describe('when required data is missing', () => { + it('should return null when activeQuote is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: undefined, + isQuoteLoading: false, + }), + { state: testState }, + ); + + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + + // Should not call Engine methods + expect(mockCall).not.toHaveBeenCalled(); + }); + + it('should return null when sourceToken is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: undefined, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + }); + + it('should return null when destToken is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: undefined, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + }); + + it('should return null when sourceAmount is missing', async () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: undefined, + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + expect(result.current).toEqual({ + shouldShowRewardsRow: false, + isLoading: false, + estimatedPoints: null, + }); + }); + }); + + describe('error handling', () => { + it('should handle rewards estimation error gracefully', async () => { + mockCall.mockImplementation((method) => { + if (method === 'RewardsController:isRewardsFeatureEnabled') { + return Promise.resolve(true); + } + if (method === 'RewardsController:getHasAccountOptedIn') { + return Promise.resolve(true); + } + if (method === 'RewardsController:estimatePoints') { + throw new Error('Network error'); + } + return Promise.resolve(null); + }); + + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: false, + }), + { state: testState }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + shouldShowRewardsRow: true, + isLoading: false, + estimatedPoints: null, + }); + }); + }); + }); + + describe('loading states', () => { + it('should show loading when quote is loading', () => { + const testState = createBridgeTestState({ + bridgeReducerOverrides: { + sourceToken: defaultSourceToken, + destToken: defaultDestToken, + sourceAmount: '1', + }, + }); + + const { result } = renderHookWithProvider( + () => + useRewards({ + activeQuote: mockActiveQuote, + isQuoteLoading: true, + }), + { state: testState }, + ); + + expect(result.current.isLoading).toBe(true); + }); + }); +}); diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts new file mode 100644 index 00000000000..5b39268be7a --- /dev/null +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -0,0 +1,201 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import Engine from '../../../../../core/Engine'; +import { + EstimatePointsDto, + EstimatedPointsDto, + EstimateAssetDto, +} from '../../../../../core/Engine/controllers/rewards-controller/types'; +import { + selectSourceToken, + selectDestToken, + selectSourceAmount, +} from '../../../../../core/redux/slices/bridge'; +import { selectSelectedInternalAccountFormattedAddress } from '../../../../../selectors/accountsController'; +import { selectChainId } from '../../../../../selectors/networkController'; +import { formatChainIdToCaip } from '@metamask/bridge-controller'; +import { + toCaipAccountId, + parseCaipChainId, + type CaipAccountId, +} from '@metamask/utils'; +import { useBridgeQuoteData } from '../useBridgeQuoteData'; +import Logger from '../../../../../util/Logger'; + +interface UseRewardsParams { + activeQuote: ReturnType['activeQuote']; + isQuoteLoading: boolean; +} + +interface UseRewardsResult { + shouldShowRewardsRow: boolean; + isLoading: boolean; + estimatedPoints: number | null; +} + +/** + * Formats an address to CAIP-10 account ID + */ +const formatAccountToCaipAccountId = ( + address: string, + chainId: string, +): CaipAccountId | null => { + try { + const caipChainId = formatChainIdToCaip(chainId); + const { namespace, reference } = parseCaipChainId(caipChainId); + return toCaipAccountId(namespace, reference, address); + } catch (error) { + Logger.error( + error as Error, + 'useRewards: Error formatting account to CAIP-10', + ); + return null; + } +}; + +export const useRewards = ({ + activeQuote, + isQuoteLoading, +}: UseRewardsParams): UseRewardsResult => { + const [isLoading, setIsLoading] = useState(false); + const [estimatedPoints, setEstimatedPoints] = useState(null); + const [shouldShowRewardsRow, setShouldShowRewardsRow] = useState(false); + + // Selectors + const sourceToken = useSelector(selectSourceToken); + const destToken = useSelector(selectDestToken); + const sourceAmount = useSelector(selectSourceAmount); + const selectedAddress = useSelector( + selectSelectedInternalAccountFormattedAddress, + ); + const currentChainId = useSelector(selectChainId); + + const estimatePoints = useCallback(async () => { + // Skip if no active quote or missing required data + if ( + !activeQuote?.quote || + !sourceToken || + !destToken || + !sourceAmount || + !selectedAddress || + !currentChainId + ) { + setEstimatedPoints(null); + return; + } + + setIsLoading(true); + + try { + // Check if rewards feature is enabled + const isRewardsEnabled = await Engine.controllerMessenger.call( + 'RewardsController:isRewardsFeatureEnabled', + ); + + if (!isRewardsEnabled) { + setEstimatedPoints(null); + setIsLoading(false); + return; + } + + // Format account to CAIP-10 + const caipAccount = formatAccountToCaipAccountId( + selectedAddress, + currentChainId, + ); + + if (!caipAccount) { + setEstimatedPoints(null); + setIsLoading(false); + return; + } + + // Check if account has opted in + const hasOptedIn = await Engine.controllerMessenger.call( + 'RewardsController:getHasAccountOptedIn', + caipAccount, + ); + + if (!hasOptedIn) { + setEstimatedPoints(null); + setIsLoading(false); + return; + } + + setShouldShowRewardsRow(true); + + // Convert source amount to atomic unit + const atomicSourceAmount = activeQuote.quote.srcTokenAmount; + + // Get destination amount from quote + const atomicDestAmount = activeQuote.quote.destTokenAmount; + + // Prepare source asset + const srcAsset: EstimateAssetDto = { + id: activeQuote.quote.srcAsset.assetId, + amount: atomicSourceAmount, + }; + + // Prepare destination asset + const destAsset: EstimateAssetDto = { + id: activeQuote.quote.destAsset.assetId, + amount: atomicDestAmount, + }; + + // Prepare fee asset (using MetaMask fee from quote data) + const feeAsset: EstimateAssetDto = { + id: activeQuote.quote.feeData.metabridge.asset.assetId, + amount: activeQuote.quote.feeData.metabridge.amount || '0', + // usdPrice: sourceToken.currencyExchangeRate?.toString(), // TODO add this once we get it from backend + }; + + // Create estimate request + const estimateRequest: EstimatePointsDto = { + activityType: 'SWAP', + account: caipAccount, + activityContext: { + swapContext: { + srcAsset, + destAsset, + feeAsset, + }, + }, + }; + + // Call rewards controller to estimate points + const result: EstimatedPointsDto = await Engine.controllerMessenger.call( + 'RewardsController:estimatePoints', + estimateRequest, + ); + + setEstimatedPoints(result.pointsEstimate); + } catch (error) { + Logger.error(error as Error, 'useRewards: Error estimating points'); + setEstimatedPoints(null); + } finally { + setIsLoading(false); + } + }, [ + activeQuote, + sourceToken, + destToken, + sourceAmount, + selectedAddress, + currentChainId, + ]); + + // Estimate points when dependencies change + useEffect(() => { + estimatePoints(); + }, [ + estimatePoints, + // Only re-estimate when quote changes (not during loading) + activeQuote?.quote?.requestId, + ]); + + return { + shouldShowRewardsRow, + isLoading: isLoading || isQuoteLoading, + estimatedPoints, + }; +}; diff --git a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx index 55a530a5c65..c7a4652f41c 100644 --- a/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderView/PerpsOrderView.tsx @@ -14,6 +14,7 @@ import React, { } from 'react'; import { ScrollView, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { notificationAsync, NotificationFeedbackType } from 'expo-haptics'; import { PerpsOrderViewSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { strings } from '../../../../../../locales/i18n'; import Button, { @@ -214,6 +215,9 @@ const PerpsOrderViewContentBase: React.FC = () => { onPress: () => toastRef?.current?.closeToast(), }, }); + + // Add haptic feedback for order confirmed + notificationAsync(NotificationFeedbackType.Success); }, onError: (error) => { toastRef?.current?.showToast({ @@ -238,6 +242,9 @@ const PerpsOrderViewContentBase: React.FC = () => { onPress: () => toastRef?.current?.closeToast(), }, }); + + // Add haptic feedback for order failed + notificationAsync(NotificationFeedbackType.Error); }, }); // Update ref when orderType changes @@ -683,6 +690,9 @@ const PerpsOrderViewContentBase: React.FC = () => { hasNoTimeout: false, // Auto-dismiss after a few seconds }); + // Add haptic feedback for order submitted + notificationAsync(NotificationFeedbackType.Warning); + // Track trade transaction submitted track(MetaMetricsEvents.PERPS_TRADE_TRANSACTION_SUBMITTED, { [PerpsEventProperties.ASSET]: orderForm.asset, diff --git a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx index 2a7834126d7..573ee49e5cb 100644 --- a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx +++ b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.test.tsx @@ -216,7 +216,7 @@ describe('PerpsConnectionErrorView', () => { />, ); - const backButton = getByText('navigation.go_back'); + const backButton = getByText('perps.errors.connectionFailed.go_back'); expect(backButton).toBeTruthy(); }); @@ -229,7 +229,7 @@ describe('PerpsConnectionErrorView', () => { />, ); - const backButton = queryByText('navigation.go_back'); + const backButton = queryByText('perps.errors.connectionFailed.go_back'); expect(backButton).toBeNull(); }); }); diff --git a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.tsx b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.tsx index 902c144e5b5..32c3838e56c 100644 --- a/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.tsx +++ b/app/components/UI/Perps/components/PerpsConnectionErrorView/PerpsConnectionErrorView.tsx @@ -47,7 +47,6 @@ const PerpsConnectionErrorView: React.FC = ({ // Determine if back button should be shown const shouldShowBackButton = showBackButton || retryAttempts > 0; - const handleGoBack = () => { // Navigate back to the previous screen or wallet home if (navigation.canGoBack()) { @@ -119,7 +118,7 @@ const PerpsConnectionErrorView: React.FC = ({ variant={ButtonVariants.Secondary} size={ButtonSize.Lg} width={ButtonWidthTypes.Full} - label={strings('navigation.go_back')} + label={strings('perps.errors.connectionFailed.go_back')} onPress={handleGoBack} style={styles.backButton} /> diff --git a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx index c4344372d9a..06ead53cc48 100644 --- a/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx +++ b/app/components/UI/Perps/components/PerpsLeverageBottomSheet/PerpsLeverageBottomSheet.tsx @@ -19,6 +19,7 @@ import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; +import { impactAsync, ImpactFeedbackStyle } from 'expo-haptics'; import { strings } from '../../../../../../locales/i18n'; import BottomSheet, { BottomSheetRef, @@ -102,6 +103,8 @@ const LeverageSlider: React.FC<{ const thumbScale = useSharedValue(1); const widthRef = useRef(0); const [gradientWidth, setGradientWidth] = useState(300); + // Track previous value for threshold detection + const previousValueRef = useRef(value); const positionToValue = useCallback( (position: number, width: number) => { @@ -139,6 +142,8 @@ const LeverageSlider: React.FC<{ // Direct assignment for instant update, no spring animation translateX.value = newPosition; } + // Update previous value ref when value changes externally + previousValueRef.current = value; }, [value, minValue, maxValue, translateX]); const progressStyle = useAnimatedStyle(() => ({ @@ -157,10 +162,42 @@ const LeverageSlider: React.FC<{ [onValueChange, onInteraction], ); + // Haptic feedback callbacks + const triggerHapticFeedback = useCallback( + (impactStyle: ImpactFeedbackStyle) => { + impactAsync(impactStyle); + }, + [], + ); + + // Check if value crosses leverage thresholds + const checkThresholdCrossing = useCallback( + (newValue: number) => { + const prevValue = previousValueRef.current; + // Define leverage thresholds based on risk levels + const thresholds = [2, 5, 10]; + + for (const threshold of thresholds) { + // Check if we crossed the threshold in either direction + if ( + (prevValue < threshold && newValue >= threshold) || + (prevValue > threshold && newValue <= threshold) + ) { + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Light); + break; + } + } + + previousValueRef.current = newValue; + }, + [triggerHapticFeedback], + ); + const panGesture = Gesture.Pan() .onBegin(() => { isPressed.value = true; thumbScale.value = 1.1; // Subtle scale effect, instant + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); }) .onUpdate((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); @@ -168,12 +205,14 @@ const LeverageSlider: React.FC<{ // Real-time value update during drag const currentValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(currentValue); + runOnJS(checkThresholdCrossing)(currentValue); }) .onEnd(() => { isPressed.value = false; thumbScale.value = 1; // Direct assignment, no spring const currentValue = positionToValue(translateX.value, sliderWidth.value); runOnJS(updateValue)(currentValue); + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); }) .onFinalize(() => { isPressed.value = false; @@ -185,6 +224,8 @@ const LeverageSlider: React.FC<{ translateX.value = newPosition; // Direct assignment for instant response const newValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(newValue); + runOnJS(checkThresholdCrossing)(newValue); + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Light); }); const composed = Gesture.Simultaneous(tapGesture, panGesture); @@ -573,6 +614,8 @@ const PerpsLeverageBottomSheet: React.FC = ({ onPress={() => { setTempLeverage(value); setInputMethod('preset'); + // Add haptic feedback for quick select buttons + impactAsync(ImpactFeedbackStyle.Light); }} > = ({ // Store width in a ref to avoid shared value dependency issues const widthRef = useRef(0); + // Track previous value for threshold detection + const previousValueRef = useRef(value); // Layout handler - pure React callback const handleLayout = useCallback( @@ -107,6 +109,8 @@ const PerpsSlider: React.FC = ({ // Direct assignment for instant update, no spring animation translateX.value = newPosition; } + // Update previous value ref when value changes externally + previousValueRef.current = value; }, [value, minimumValue, maximumValue, translateX, widthRef]); // Animated styles @@ -126,12 +130,47 @@ const PerpsSlider: React.FC = ({ [onValueChange], ); + // Haptic feedback callbacks + const triggerHapticFeedback = useCallback( + (impactStyle: ImpactFeedbackStyle) => { + impactAsync(impactStyle); + }, + [], + ); + + // Check if value crosses 25/50/75 thresholds + const checkThresholdCrossing = useCallback( + (newValue: number) => { + const prevValue = previousValueRef.current; + const thresholds = [25, 50, 75]; + + for (const threshold of thresholds) { + const prevPercentage = + (prevValue / (maximumValue - minimumValue)) * 100; + const newPercentage = (newValue / (maximumValue - minimumValue)) * 100; + + // Check if we crossed the threshold in either direction + if ( + (prevPercentage < threshold && newPercentage >= threshold) || + (prevPercentage > threshold && newPercentage <= threshold) + ) { + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Light); + break; + } + } + + previousValueRef.current = newValue; + }, + [maximumValue, minimumValue, triggerHapticFeedback], + ); + // Pan gesture for dragging const panGesture = Gesture.Pan() .enabled(!disabled) .onBegin(() => { isPressed.value = true; thumbScale.value = 1.1; // Subtle scale effect, instant + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); }) .onUpdate((event) => { const newPosition = Math.max(0, Math.min(event.x, sliderWidth.value)); @@ -139,12 +178,14 @@ const PerpsSlider: React.FC = ({ // Real-time value update during drag const currentValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(currentValue); + runOnJS(checkThresholdCrossing)(currentValue); }) .onEnd(() => { isPressed.value = false; thumbScale.value = 1; // Direct assignment, no spring const currentValue = positionToValue(translateX.value, sliderWidth.value); runOnJS(updateValue)(currentValue); + runOnJS(triggerHapticFeedback)(ImpactFeedbackStyle.Medium); }) .onFinalize(() => { isPressed.value = false; @@ -159,6 +200,7 @@ const PerpsSlider: React.FC = ({ translateX.value = newPosition; // Direct assignment for instant response const newValue = positionToValue(newPosition, sliderWidth.value); runOnJS(updateValue)(newValue); + runOnJS(checkThresholdCrossing)(newValue); }); // Combined gesture @@ -171,8 +213,15 @@ const PerpsSlider: React.FC = ({ const newValue = (percent / 100) * (maximumValue - minimumValue) + minimumValue; onValueChange(newValue); + checkThresholdCrossing(newValue); }, - [disabled, maximumValue, minimumValue, onValueChange], + [ + disabled, + maximumValue, + minimumValue, + onValueChange, + checkThresholdCrossing, + ], ); const percentageSteps = [0, 25, 50, 75, 100]; @@ -272,7 +321,13 @@ const PerpsSlider: React.FC = ({ {quickValues && ( {quickValues.map((val) => ( - onValueChange(val)}> + { + onValueChange(val); + checkThresholdCrossing(val); + }} + > {val}x ))} diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx index 4c508a85bad..102416e46c0 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.test.tsx @@ -38,9 +38,13 @@ jest.mock('../components/PerpsConnectionErrorView', () => ({ default: ({ error, onRetry, + showBackButton, + retryAttempts, }: { error: string | Error; onRetry: () => void; + showBackButton?: boolean; + retryAttempts?: number; }) => { const { View, Text, TouchableOpacity } = jest.requireActual('react-native'); return ( @@ -49,6 +53,12 @@ jest.mock('../components/PerpsConnectionErrorView', () => ({ Retry + {showBackButton && ( + Back button shown + )} + {retryAttempts !== undefined && ( + {retryAttempts} + )} ); }, @@ -498,6 +508,293 @@ describe('PerpsConnectionProvider', () => { mockDisconnect.mockResolvedValue(undefined); }); + describe('isFullScreen prop behavior', () => { + it('should show back button immediately when isFullScreen is true', async () => { + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId } = render( + + Test + , + ); + + // Back button should be shown immediately when isFullScreen is true + await waitFor(() => { + expect(getByTestId('perps-connection-error')).toBeDefined(); + expect(getByTestId('back-button-indicator')).toBeDefined(); + }); + }); + + it('should not show back button initially when isFullScreen is false', async () => { + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId, queryByTestId } = render( + + Test + , + ); + + // Back button should NOT be shown initially when isFullScreen is false + await waitFor(() => { + expect(getByTestId('perps-connection-error')).toBeDefined(); + expect(queryByTestId('back-button-indicator')).toBeNull(); + }); + }); + + it('should show back button after retry when isFullScreen is false', async () => { + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId, queryByTestId } = render( + + Test + , + ); + + // Initially no back button + expect(queryByTestId('back-button-indicator')).toBeNull(); + + // Mock connection failure on retry + mockConnect.mockRejectedValueOnce(new Error('Retry failed')); + + // Click retry button + const retryButton = getByTestId('retry-button'); + act(() => { + retryButton.props.onPress(); + }); + + // After retry attempt, back button should be shown + await waitFor(() => { + expect(getByTestId('back-button-indicator')).toBeDefined(); + expect(getByTestId('retry-attempts')).toHaveTextContent('1'); + }); + }); + }); + + describe('retry logic behavior', () => { + it('should call PerpsConnectionManager.connect directly on retry', async () => { + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId } = render( + + Test + , + ); + + // Clear previous mock calls + mockConnect.mockClear(); + + // Click retry button + const retryButton = getByTestId('retry-button'); + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + // Should call PerpsConnectionManager.connect directly + expect(mockConnect).toHaveBeenCalledTimes(1); + }); + }); + + it('should update state after retry attempt', async () => { + // Mock initial error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId, queryByTestId, getByText } = render( + + Child Component + , + ); + + // Should show error view + expect(getByTestId('perps-connection-error')).toBeDefined(); + + // Mock successful connection on retry + mockConnect.mockResolvedValueOnce(undefined); + + // Update mock to return connected state after retry + mockGetConnectionState.mockReturnValue({ + isConnected: true, + isConnecting: false, + isInitialized: true, + error: null, + }); + + // Click retry button + const retryButton = getByTestId('retry-button'); + await act(async () => { + retryButton.props.onPress(); + }); + + // Should now show children instead of error view + await waitFor(() => { + expect(queryByTestId('perps-connection-error')).toBeNull(); + expect(getByText('Child Component')).toBeDefined(); + }); + }); + + it('should increment retry attempts on each retry', async () => { + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId } = render( + + Test + , + ); + + // Mock connection failures + mockConnect.mockRejectedValue(new Error('Connection failed')); + + // First retry + const retryButton = getByTestId('retry-button'); + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + expect(getByTestId('retry-attempts')).toHaveTextContent('1'); + }); + + // Second retry + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + expect(getByTestId('retry-attempts')).toHaveTextContent('2'); + }); + + // Third retry + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + expect(getByTestId('retry-attempts')).toHaveTextContent('3'); + }); + }); + + it('should reset retry attempts on successful connection', async () => { + // Mock initial error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId, queryByTestId } = render( + + Child Component + , + ); + + // Mock connection failure then success + mockConnect + .mockRejectedValueOnce(new Error('First retry failed')) + .mockResolvedValueOnce(undefined); + + // First retry (fails) + const retryButton = getByTestId('retry-button'); + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + expect(getByTestId('retry-attempts')).toHaveTextContent('1'); + }); + + // Update mock to return connected state for second retry + mockGetConnectionState.mockReturnValue({ + isConnected: true, + isConnecting: false, + isInitialized: true, + error: null, + }); + + // Second retry (succeeds) + act(() => { + retryButton.props.onPress(); + }); + + // Should clear retry attempts and show children + await waitFor(() => { + expect(queryByTestId('perps-connection-error')).toBeNull(); + expect(queryByTestId('retry-attempts')).toBeNull(); + }); + }); + + it('should not call resetError during retry', async () => { + // Mock resetError + const mockResetError = jest.fn(); + (PerpsConnectionManager.resetError as jest.Mock) = mockResetError; + + // Mock error state + mockGetConnectionState.mockReturnValue({ + isConnected: false, + isConnecting: false, + isInitialized: false, + error: 'Connection failed', + }); + + const { getByTestId } = render( + + Test + , + ); + + // Clear previous mock calls + mockResetError.mockClear(); + + // Click retry button + const retryButton = getByTestId('retry-button'); + act(() => { + retryButton.props.onPress(); + }); + + await waitFor(() => { + // resetError should NOT be called during retry + expect(mockResetError).not.toHaveBeenCalled(); + // But connect should be called + expect(mockConnect).toHaveBeenCalled(); + }); + }); + }); + it('should clean up polling interval on unmount', () => { // Reset disconnect mock to default behavior mockDisconnect.mockResolvedValue(undefined); diff --git a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx index 5cf0ef639a8..3c25e2400c6 100644 --- a/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx +++ b/app/components/UI/Perps/providers/PerpsConnectionProvider.tsx @@ -31,6 +31,7 @@ const PerpsConnectionContext = interface PerpsConnectionProviderProps { children: React.ReactNode; isVisible?: boolean; + isFullScreen?: boolean; } /** @@ -41,7 +42,7 @@ interface PerpsConnectionProviderProps { */ export const PerpsConnectionProvider: React.FC< PerpsConnectionProviderProps -> = ({ children, isVisible }) => { +> = ({ children, isVisible, isFullScreen = false }) => { const [connectionState, setConnectionState] = useState(() => PerpsConnectionManager.getConnectionState(), ); @@ -194,23 +195,30 @@ export const PerpsConnectionProvider: React.FC< // This ensures NO Perps screen can render when there's a connection error if (connectionState.error) { // Determine if back button should be shown based on navigation context - // For now, only show back button after retry failures (conservative approach) - // This prevents showing back button in wallet tabs while ensuring escape route exists - const shouldShowBackButton = retryAttempts > 0; + // Always show back button when in full screen mode (e.g., stack navigator) + // Also show it after retry attempts for other contexts + const shouldShowBackButton = isFullScreen || retryAttempts > 0; const handleRetry = async () => { + // Increment retry attempts first to ensure back button shows immediately setRetryAttempts((prev) => prev + 1); try { - resetError(); // Clear normal errors - await connect(); // Attempt reconnection + // Attempt to connect directly using the singleton + // This will either succeed or throw an error + await PerpsConnectionManager.connect(); - // Reset retry attempts on successful connection + // If we reach here, connection succeeded + // Reset retry attempts and let polling update the state setRetryAttempts(0); } catch (err) { // Keep retry attempts count for showing back button after failed attempts console.error('Retry connection failed:', err); } + + // Force update to get the latest error state + const state = PerpsConnectionManager.getConnectionState(); + setConnectionState(state); }; return ( diff --git a/app/components/UI/Perps/routes/index.tsx b/app/components/UI/Perps/routes/index.tsx index 9a3b29b6dfc..fd0b6065aa3 100644 --- a/app/components/UI/Perps/routes/index.tsx +++ b/app/components/UI/Perps/routes/index.tsx @@ -46,7 +46,7 @@ const PerpsModalStack = () => ( ); const PerpsScreenStack = () => ( - + {/* Redirect to wallet perps tab */} diff --git a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx index 1a3c9fa8b33..d8401b51d3c 100644 --- a/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx +++ b/app/components/UI/shared/BaseControlBar/BaseControlBar.tsx @@ -37,6 +37,7 @@ import { } from '../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import { useStyles } from '../../../hooks/useStyles'; import createControlBarStyles from '../ControlBarStyles'; +import { WalletViewSelectorsIDs } from '../../../../../e2e/selectors/wallet/WalletView.selectors'; export interface BaseControlBarProps { /** @@ -184,6 +185,7 @@ const BaseControlBar: React.FC = ({ const sortButton = ( { + const actual = jest.requireActual( + '../../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', + ); + return { + ...actual, + selectMultichainAccountsState2Enabled: jest.fn(() => false), + }; + }, +); + jest.mock('@react-navigation/native', () => { const actualNav = jest.requireActual('@react-navigation/native'); return { @@ -36,6 +52,27 @@ const mockInitialState = { settings: { useBlockieIcon: false, }, + engine: { + backgroundState: { + ...backgroundState, + RemoteFeatureFlagController: { + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: false, + featureVersion: null, + minimumVersion: null, + }, + }, + }, + AccountTreeController: { + accountTree: { + wallets: {}, + }, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }, + }, + }, }; describe('BaseAccountDetails', () => { @@ -112,10 +149,32 @@ describe('BaseAccountDetails', () => { expect(mockGoBack).toHaveBeenCalledTimes(1); }); - it('navigates to edit account name when account name is pressed', () => { + it('navigates to edit account name when account name is pressed (legacy mode)', () => { + // Mock feature flag as disabled (legacy mode) + const mockState = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + RemoteFeatureFlagController: { + ...mockInitialState.engine.backgroundState + .RemoteFeatureFlagController, + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: false, + featureVersion: null, + minimumVersion: null, + }, + }, + }, + }, + }, + }; + const { getByTestId } = renderWithProvider( , - { state: mockInitialState }, + { state: mockState }, ); const accountNameLink = getByTestId(AccountDetailsIds.ACCOUNT_NAME_LINK); @@ -124,12 +183,82 @@ describe('BaseAccountDetails', () => { expect(mockNavigate).toHaveBeenCalledWith( Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { - screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME, + screen: + Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.LEGACY_EDIT_ACCOUNT_NAME, params: { account: mockAccount }, }, ); }); + it('navigates to multichain edit account name when account name is pressed (state 2 enabled)', () => { + // Mock feature flag as enabled (state 2 mode) + const { selectMultichainAccountsState2Enabled } = jest.requireMock( + '../../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', + ); + (selectMultichainAccountsState2Enabled as jest.Mock).mockReturnValue(true); + const mockState = { + ...mockInitialState, + engine: { + ...mockInitialState.engine, + backgroundState: { + ...mockInitialState.engine.backgroundState, + RemoteFeatureFlagController: { + ...mockInitialState.engine.backgroundState + .RemoteFeatureFlagController, + remoteFeatureFlags: { + enableMultichainAccounts: { + enabled: true, + featureVersion: '2', + minimumVersion: '1.0.0', + }, + }, + }, + AccountTreeController: { + accountTree: { + wallets: { + 'keyring:test-wallet': { + id: 'keyring:test-wallet', + metadata: { name: 'Test Wallet' }, + type: AccountWalletType.Keyring, + groups: { + 'keyring:test-wallet/ethereum': { + id: 'keyring:test-wallet/ethereum', + accounts: [mockAccount.id], + metadata: { name: 'Test Account Group' }, + type: AccountGroupType.SingleAccount, + }, + }, + }, + }, + }, + accountGroupsMetadata: {}, + accountWalletsMetadata: {}, + }, + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: mockState as unknown as RootState }, + ); + + const accountNameLink = getByTestId(AccountDetailsIds.ACCOUNT_NAME_LINK); + fireEvent.press(accountNameLink); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME, + { + accountGroup: { + id: 'keyring:test-wallet/ethereum', + accounts: [mockAccount.id], + metadata: { name: 'Test Account Group' }, + type: AccountGroupType.SingleAccount, + }, + }, + ); + }); + it('navigates to share address when account address is pressed', () => { const { getByTestId } = renderWithProvider( , diff --git a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx index 57e65b77f59..ea147b16fcb 100644 --- a/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx +++ b/app/components/Views/MultichainAccounts/AccountDetails/AccountTypes/BaseAccountDetails/index.tsx @@ -1,6 +1,7 @@ import React, { useCallback } from 'react'; import { SafeAreaView, ScrollView, TouchableOpacity } from 'react-native'; import { InternalAccount } from '@metamask/keyring-internal-api'; +import { AccountGroupObject } from '@metamask/account-tree-controller'; import { strings } from '../../../../../../../locales/i18n'; import styleSheet from './styles'; import Text, { @@ -30,7 +31,16 @@ import { useStyles } from '../../../../../hooks/useStyles'; import { AccountDetailsIds } from '../../../../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../../../reducers'; -import { selectWalletByAccount } from '../../../../../../selectors/multichainAccounts/accountTreeController'; +import { + selectWalletByAccount, + selectAccountToGroupMap, +} from '../../../../../../selectors/multichainAccounts/accountTreeController'; +import { selectMultichainAccountsState2Enabled } from '../../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; +import { createNavigationDetails } from '../../../../../../util/navigation/navUtils'; + +export const createEditAccountNameNavigationDetails = createNavigationDetails<{ + accountGroup: AccountGroupObject; +}>(Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME); interface BaseAccountDetailsProps { account: InternalAccount; @@ -52,12 +62,37 @@ export const BaseAccountDetails = ({ const selectWallet = useSelector(selectWalletByAccount); const wallet = selectWallet?.(account.id); + // Feature flag and selectors for conditional navigation + const isMultichainAccountsState2Enabled = useSelector( + selectMultichainAccountsState2Enabled, + ); + const accountToGroupMap = useSelector(selectAccountToGroupMap); + const handleEditAccountName = useCallback(() => { - navigation.navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { - screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME, - params: { account }, - }); - }, [navigation, account]); + if (isMultichainAccountsState2Enabled) { + // Use multichain edit account name for account groups (same as MultichainAccountActions) + const accountGroup = accountToGroupMap[account.id]; + if (accountGroup) { + navigation.navigate( + ...createEditAccountNameNavigationDetails({ + accountGroup, + }), + ); + } + } else { + // Use legacy edit account name for individual accounts + navigation.navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { + screen: + Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.LEGACY_EDIT_ACCOUNT_NAME, + params: { account }, + }); + } + }, [ + navigation, + account, + isMultichainAccountsState2Enabled, + accountToGroupMap, + ]); const handleShareAddress = useCallback(() => { navigation.navigate(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS, { diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx index 4d495741596..29b341541be 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.test.tsx @@ -315,4 +315,21 @@ describe('AccountGroupDetails', () => { account: expect.any(Object), }); }); + + it('navigates to edit account name when account name is pressed', () => { + const { getByTestId } = renderWithProvider( + , + { state: mockState }, + ); + const accountNameLink = getByTestId(AccountDetailsIds.ACCOUNT_NAME_LINK); + fireEvent.press(accountNameLink); + + expect(mockNavigate).toHaveBeenCalledWith( + 'MultichainAccountDetailActions', + { + screen: 'EditMultichainAccountName', + params: { accountGroup: mockAccountGroup }, + }, + ); + }); }); diff --git a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx index a6d80c3bdc0..f954147b4fa 100644 --- a/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx +++ b/app/components/Views/MultichainAccounts/AccountGroupDetails/AccountGroupDetails.tsx @@ -28,7 +28,10 @@ import { useStyles } from '../../../hooks/useStyles'; import { AccountDetailsIds } from '../../../../../e2e/selectors/MultichainAccounts/AccountDetails.selectors'; import { useSelector } from 'react-redux'; import { RootState } from '../../../../reducers'; -import { selectWalletById } from '../../../../selectors/multichainAccounts/accountTreeController'; +import { + selectWalletById, + selectAccountGroupById, +} from '../../../../selectors/multichainAccounts/accountTreeController'; import { selectInternalAccountListSpreadByScopesByGroupId, getWalletIdFromAccountGroup, @@ -39,6 +42,16 @@ import { selectInternalAccountsById } from '../../../../selectors/accountsContro import { SecretRecoveryPhrase, Wallet, RemoveAccount } from './components'; import { createAddressListNavigationDetails } from '../AddressList'; import { createPrivateKeyListNavigationDetails } from '../PrivateKeyList/PrivateKeyList'; +import Routes from '../../../../constants/navigation/Routes'; +import { createMultichainAccountDetailActionsModalNavigationDetails } from '../sheets/MultichainAccountActions/MultichainAccountActions'; + +const createEditAccountNameNavigationDetails = ( + accountGroup: AccountGroupObject, +) => + createMultichainAccountDetailActionsModalNavigationDetails({ + screen: Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME, + params: { accountGroup }, + }); interface AccountGroupDetailsProps { route: { @@ -50,9 +63,15 @@ interface AccountGroupDetailsProps { export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { const navigation = useNavigation(); - const { - accountGroup: { id, metadata, type, accounts }, - } = props.route.params; + const { accountGroup: initialAccountGroup } = props.route.params; + const { id } = initialAccountGroup; + + // Use selector to get current account group data from Redux store + const accountGroup = + useSelector((state: RootState) => selectAccountGroupById(state, id)) || + initialAccountGroup; + + const { metadata, type, accounts } = accountGroup; const groupName = useMemo(() => metadata.name, [metadata.name]); const walletId = useMemo(() => getWalletIdFromAccountGroup(id), [id]); const { styles, theme } = useStyles(styleSheet, {}); @@ -98,6 +117,12 @@ export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { navigation.navigate('SmartAccountDetails', { account }); }, [navigation, account]); + const handleEditAccountName = useCallback(() => { + navigation.navigate( + ...createEditAccountNameNavigationDetails(accountGroup), + ); + }, [navigation, accountGroup]); + return ( { {strings('multichain_accounts.account_details.account_name')} @@ -144,6 +170,11 @@ export const AccountGroupDetails = (props: AccountGroupDetailsProps) => { {groupName} + void; } export const AddAccountItem: React.FC = ({ + index, totalItemsCount, + isLoading, onPress, }) => { - const { styles, theme } = useStyles(styleSheet, {}); - const { colors } = theme; + const { styles } = useStyles(styleSheet, {}); - const boxStyles: ViewStyle[] = [styles.addAccountBox]; + const boxStyles: ViewStyle[] = [styles.accountBox]; if (totalItemsCount > 1) { - boxStyles.push(styles.lastAccountBox); + if (index === 0) { + boxStyles.push(styles.firstAccountBox); + } else if (index === totalItemsCount - 1) { + boxStyles.push(styles.lastAccountBox); + } } return ( - - - - {strings('multichain_accounts.wallet_details.create_account')} - + + {isLoading ? ( + + ) : ( + + )} + + {isLoading + ? strings('multichain_accounts.wallet_details.creating_account') + : strings('multichain_accounts.wallet_details.create_account')} + ); diff --git a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx index 5f477d61df7..f9c76596945 100644 --- a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx +++ b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.test.tsx @@ -22,10 +22,85 @@ import { selectAccountGroupsByWallet, } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { backgroundState } from '../../../../../util/test/initial-root-state'; +import Engine from '../../../../../core/Engine'; +import Logger from '../../../../../util/Logger'; jest.mock('../utils/getInternalAccountsFromWallet'); jest.mock('../hooks/useWalletBalances'); jest.mock('../hooks/useWalletInfo'); +jest.mock('../../../../../core/Engine', () => ({ + context: { + AccountsController: { + state: { + internalAccounts: { + accounts: {}, + selectedAccount: '', + }, + }, + }, + MultichainAccountService: { + createNextMultichainAccountGroup: jest.fn(), + }, + }, +})); +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +// Mock FlashList to avoid rendering issues - using proper imports +jest.mock('@shopify/flash-list', () => { + const ReactMock = jest.requireActual('react'); + const { View } = jest.requireActual('react-native'); + + const MockFlashList = ReactMock.forwardRef( + ( + props: { + data: unknown[]; + keyExtractor?: (item: unknown, index: number) => string; + renderItem: ({ + item, + index, + }: { + item: unknown; + index: number; + }) => React.ReactElement; + }, + ref: React.Ref<{ + scrollToEnd: () => void; + scrollToIndex: (index: number) => void; + scrollToOffset: (offset: number) => void; + }>, + ) => { + const { data, keyExtractor, renderItem, ...otherProps } = props; + + // Create a mock ref with scrollToEnd method + ReactMock.useImperativeHandle(ref, () => ({ + scrollToEnd: jest.fn(), + scrollToIndex: jest.fn(), + scrollToOffset: jest.fn(), + })); + + return ( + + {data?.map((item: unknown, index: number) => { + const key = keyExtractor + ? keyExtractor(item, index) + : index.toString(); + return ( + + {renderItem({ item, index })} + + ); + })} + + ); + }, + ); + + return { + FlashList: MockFlashList, + }; +}); // Mock the multichain accounts feature flag selector jest.mock( @@ -60,20 +135,6 @@ jest.mock('../../../../../core/SnapKeyring/MultichainWalletSnapClient', () => ({ }, })); -// Mock Engine to prevent undefined internalAccounts error -jest.mock('../../../../../core/Engine', () => ({ - context: { - AccountsController: { - state: { - internalAccounts: { - accounts: {}, - selectedAccount: '', - }, - }, - }, - }, -})); - const mockGetInternalAccountsFromWallet = getInternalAccountsFromWallet as jest.Mock; const mockUseWalletBalances = useWalletBalances as jest.Mock; @@ -326,23 +387,25 @@ describe('BaseWalletDetails', () => { expect(queryByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON)).toBeNull(); }); - it('opens add account modal when add account button is pressed', () => { + it('opens add account modal when add account button is pressed (state 2 disabled)', () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); + const { getByTestId, queryByText, getByText } = renderWithProvider( , { state: mockInitialState }, ); - // Modal should not be visible initially expect(queryByText('Create a new account')).toBeNull(); const addAccountButton = getByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON); fireEvent.press(addAccountButton); - // Modal should be visible after pressing add account button expect(getByText('Create a new account')).toBeTruthy(); }); - it('shows Ethereum and Solana account options in modal', () => { + it('shows Ethereum and Solana account options in modal (state 2 disabled)', () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); + const { getByTestId, getByText } = renderWithProvider( , { state: mockInitialState }, @@ -351,27 +414,209 @@ describe('BaseWalletDetails', () => { const addAccountButton = getByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON); fireEvent.press(addAccountButton); - // Check that both account options are rendered expect(getByText('Ethereum account')).toBeTruthy(); expect(getByText('Solana account')).toBeTruthy(); }); - it('does not open modal when keyringId is null and button is pressed', () => { + it('does not create account when keyringId is null and button is pressed', () => { mockUseWalletInfo.mockReturnValue({ accounts: [mockAccount1, mockAccount2], keyringId: null, - srpIndex: 1, isSRPBackedUp: true, }); - const { queryByTestId, queryByText } = renderWithProvider( + const { queryByTestId } = renderWithProvider( , { state: mockInitialState }, ); // Button should not be rendered when keyringId is null expect(queryByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON)).toBeNull(); - expect(queryByText('Create a new account')).toBeNull(); + }); + + describe('handleCreateAccount function', () => { + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + // Reset the default mock implementations + mockUseWalletInfo.mockReturnValue({ + accounts: [mockAccount1, mockAccount2], + keyringId: '1', + isSRPBackedUp: true, + }); + }); + + it('successfully creates account when state 2 is enabled', async () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + const mockCreateNextMultichainAccountGroup = jest + .fn() + .mockResolvedValue(undefined); + Engine.context.MultichainAccountService.createNextMultichainAccountGroup = + mockCreateNextMultichainAccountGroup; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addAccountButton = getByTestId( + WalletDetailsIds.ADD_ACCOUNT_BUTTON, + ); + fireEvent.press(addAccountButton); + + // Wait for async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCreateNextMultichainAccountGroup).toHaveBeenCalledWith({ + entropySource: '1', + }); + expect(mockCreateNextMultichainAccountGroup).toHaveBeenCalledTimes(1); + }); + + it('handles error when account creation fails', async () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + const mockError = new Error('Account creation failed'); + const mockCreateNextMultichainAccountGroup = jest + .fn() + .mockRejectedValue(mockError); + Engine.context.MultichainAccountService.createNextMultichainAccountGroup = + mockCreateNextMultichainAccountGroup; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addAccountButton = getByTestId( + WalletDetailsIds.ADD_ACCOUNT_BUTTON, + ); + fireEvent.press(addAccountButton); + + // Wait for async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCreateNextMultichainAccountGroup).toHaveBeenCalledWith({ + entropySource: '1', + }); + expect(Logger.error).toHaveBeenCalledWith( + mockError, + 'error while trying to add a new multichain account', + ); + }); + + it('does not render add account button when keyringId is falsy', () => { + mockUseWalletInfo.mockReturnValue({ + accounts: [mockAccount1, mockAccount2], + keyringId: undefined, + isSRPBackedUp: true, + }); + + const { queryByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(queryByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON)).toBeNull(); + }); + + it('calls createNextMultichainAccountGroup with correct parameters', async () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + const mockCreateNextMultichainAccountGroup = jest + .fn() + .mockResolvedValue(undefined); + Engine.context.MultichainAccountService.createNextMultichainAccountGroup = + mockCreateNextMultichainAccountGroup; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addAccountButton = getByTestId( + WalletDetailsIds.ADD_ACCOUNT_BUTTON, + ); + fireEvent.press(addAccountButton); + + // Wait for async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCreateNextMultichainAccountGroup).toHaveBeenCalledWith({ + entropySource: '1', + }); + }); + + it('handles account creation for both state 1 and state 2', async () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + const mockCreateNextMultichainAccountGroup = jest + .fn() + .mockResolvedValue(undefined); + Engine.context.MultichainAccountService.createNextMultichainAccountGroup = + mockCreateNextMultichainAccountGroup; + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addAccountButton = getByTestId( + WalletDetailsIds.ADD_ACCOUNT_BUTTON, + ); + fireEvent.press(addAccountButton); + + // Wait for async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCreateNextMultichainAccountGroup).toHaveBeenCalledWith({ + entropySource: '1', + }); + + // Reset mock for second test + mockCreateNextMultichainAccountGroup.mockClear(); + mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); + + const { getByTestId: getByTestIdLegacy } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const addAccountButtonLegacy = getByTestIdLegacy( + WalletDetailsIds.ADD_ACCOUNT_BUTTON, + ); + fireEvent.press(addAccountButtonLegacy); + + // Wait for async operation to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mockCreateNextMultichainAccountGroup).not.toHaveBeenCalled(); + }); + }); + + describe('handleGoToAccountDetails function', () => { + it('navigates to account details when account item is pressed', () => { + mockUseWalletInfo.mockReturnValue({ + accounts: [mockAccount1, mockAccount2], + keyringId: 'keyring:1', + isSRPBackedUp: true, + }); + + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + const firstAccountItem = getByTestId( + `${WalletDetailsIds.ACCOUNT_ITEM}_${mockAccount1.id}`, + ); + + fireEvent.press(firstAccountItem); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_DETAILS, + { + account: mockAccount1, + }, + ); + }); }); }); @@ -591,4 +836,44 @@ describe('BaseWalletDetails', () => { expect(mockNavigate).not.toHaveBeenCalled(); }); }); + + describe('keyExtractor logic', () => { + it('generates correct keys for add-account vs regular items in legacy view', () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(getByTestId(`flash-list-item-${mockAccount1.id}`)).toBeTruthy(); + expect(getByTestId('flash-list-item-add-account-2')).toBeTruthy(); + }); + + it('generates correct keys for add-account vs regular items in state 2 view', () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(true); + const { getByTestId } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect( + getByTestId(`flash-list-item-${mockAccountGroup1.id}`), + ).toBeTruthy(); + expect(getByTestId('flash-list-item-add-account-2')).toBeTruthy(); + }); + }); + + describe('handleCloseAddAccountModal function', () => { + it('opens and closes modal correctly', () => { + mockSelectMultichainAccountsState2Enabled.mockReturnValue(false); + const { getByTestId, queryByText } = renderWithProvider( + , + { state: mockInitialState }, + ); + + expect(queryByText('Create a new account')).toBeNull(); + fireEvent.press(getByTestId(WalletDetailsIds.ADD_ACCOUNT_BUTTON)); + expect(queryByText('Create a new account')).toBeTruthy(); + }); + }); }); diff --git a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.tsx b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.tsx index 4126361ddd0..2131add4134 100644 --- a/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.tsx +++ b/app/components/Views/MultichainAccounts/WalletDetails/BaseWalletDetails/index.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useMemo } from 'react'; +import React, { useCallback, useState, useMemo, useRef } from 'react'; import { useNavigation } from '@react-navigation/native'; import { TouchableOpacity, View } from 'react-native'; import { useStyles } from '../../../../hooks/useStyles'; @@ -27,20 +27,26 @@ import { AccountGroupObject, } from '@metamask/account-tree-controller'; import { InternalAccount } from '@metamask/keyring-internal-api'; + +// Type for combined account data that includes the add account item +type AccountListItem = InternalAccount | { type: 'add-account' }; +type AccountGroupListItem = AccountGroupObject | { type: 'add-account' }; import { useWalletBalances } from '../hooks/useWalletBalances'; import { useSelector } from 'react-redux'; import AnimatedSpinner, { SpinnerSize } from '../../../../UI/AnimatedSpinner'; import { useWalletInfo } from '../hooks/useWalletInfo'; import Routes from '../../../../../constants/navigation/Routes'; -import WalletAddAccountActions from './components/WalletAddAccountActions'; -import AddAccountItem from './components/AddAccountItem'; -import { FlashList } from '@shopify/flash-list'; +import { FlashList, FlashListRef } from '@shopify/flash-list'; import AccountCell from '../../../../../component-library/components-temp/MultichainAccounts/AccountCell/AccountCell'; import { selectAccountGroupsByWallet } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { selectMultichainAccountsState2Enabled } from '../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; import AccountItem from './components/AccountItem'; +import AddAccountItem from './components/AddAccountItem'; import { AvatarAccountType } from '../../../../../component-library/components/Avatars/Avatar'; import { RootState } from '../../../../../reducers'; +import Logger from '../../../../../util/Logger'; +import Engine from '../../../../../core/Engine'; +import WalletAddAccountActions from './components/WalletAddAccountActions'; interface BaseWalletDetailsProps { wallet: AccountWalletObject; @@ -54,7 +60,13 @@ export const BaseWalletDetails = ({ const navigation = useNavigation(); const { styles, theme } = useStyles(styleSheet, {}); const { colors } = theme; + const [isLoading, setIsLoading] = useState(false); const [showAddAccountModal, setShowAddAccountModal] = useState(false); + const accountGroupsFlashListRef = + useRef | null>(null); + const accountsFlashListRef = useRef | null>( + null, + ); const { accounts, keyringId, isSRPBackedUp } = useWalletInfo(wallet); @@ -70,6 +82,17 @@ export const BaseWalletDetails = ({ [accountGroupsByWallet, wallet.id], ); + // Combined data arrays that include the add account item as the last element + const accountGroupsWithAddItem = useMemo(() => { + const addAccountItem = { type: 'add-account' as const }; + return keyringId ? [...accountGroups, addAccountItem] : accountGroups; + }, [accountGroups, keyringId]); + + const accountsWithAddItem = useMemo(() => { + const addAccountItem = { type: 'add-account' as const }; + return keyringId ? [...accounts, addAccountItem] : accounts; + }, [accounts, keyringId]); + const { formattedWalletTotalBalance, multichainBalancesForAllAccounts } = useWalletBalances(wallet.id); @@ -95,16 +118,6 @@ export const BaseWalletDetails = ({ }); }, [navigation]); - const handleAddAccount = useCallback(() => { - if (keyringId) { - setShowAddAccountModal(true); - } - }, [keyringId]); - - const handleCloseAddAccountModal = useCallback(() => { - setShowAddAccountModal(false); - }, []); - const handleGoToAccountDetails = useCallback( (account: InternalAccount) => { navigation.navigate(Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_DETAILS, { @@ -114,14 +127,73 @@ export const BaseWalletDetails = ({ [navigation], ); + const handleCreateAccount = useCallback(async () => { + try { + const { MultichainAccountService } = Engine.context; + + await MultichainAccountService.createNextMultichainAccountGroup({ + entropySource: keyringId as string, + }); + + setIsLoading(false); + + // Scroll to the bottom to show the newly created account + const currentRef = isMultichainAccountsState2Enabled + ? accountGroupsFlashListRef.current + : accountsFlashListRef.current; + + if (currentRef) { + // Use a small delay to ensure the new account is rendered + setTimeout(() => { + currentRef?.scrollToEnd({ animated: true }); + }, 100); + } + } catch (e: unknown) { + Logger.error( + e as Error, + 'error while trying to add a new multichain account', + ); + setIsLoading(false); + } + }, [keyringId, isMultichainAccountsState2Enabled]); + + const handlePress = useCallback(() => { + if (isMultichainAccountsState2Enabled) { + // Force immediate state update + setIsLoading(true); + + // Create account immediately without delay + handleCreateAccount(); + } else { + // Show the legacy add account modal for state 1 + setShowAddAccountModal(true); + } + }, [handleCreateAccount, isMultichainAccountsState2Enabled]); + + const handleCloseAddAccountModal = useCallback(() => { + setShowAddAccountModal(false); + }, []); + // Render account group item for multichain accounts state 2 const renderAccountGroupItem = ({ item: accountGroup, index, }: { - item: AccountGroupObject; + item: AccountGroupListItem; index: number; }) => { + // Handle add account item + if ('type' in accountGroup && accountGroup.type === 'add-account') { + return ( + + ); + } + const isFirst = index === 0; const accountBoxStyle = [ @@ -141,10 +213,21 @@ export const BaseWalletDetails = ({ item: account, index, }: { - item: InternalAccount; + item: AccountListItem; index: number; }) => { - const totalItemsCount = keyringId ? accounts.length + 1 : accounts.length; // Include add account item if keyringId exists + // Handle add account item + if ('type' in account && account.type === 'add-account') { + return ( + + ); + } + const accountBalance = multichainBalancesForAllAccounts[account.id]; const isAccountBalanceLoading = !accountBalance; @@ -152,7 +235,7 @@ export const BaseWalletDetails = ({ { - const totalItemsCount = isMultichainAccountsState2Enabled - ? accountGroups.length + 1 - : accounts.length + 1; - - return ( - - ); - }; - const renderAccountsList = () => { if (isMultichainAccountsState2Enabled) { return ( item.id} + data={accountGroupsWithAddItem} + keyExtractor={(item, index) => + 'type' in item && item.type === 'add-account' + ? `add-account-${index}` + : item.id + } renderItem={renderAccountGroupItem} + ref={accountGroupsFlashListRef} /> ); } return ( item.id} + data={accountsWithAddItem} + keyExtractor={(item, index) => + 'type' in item && item.type === 'add-account' + ? `add-account-${index}` + : item.id + } renderItem={renderAccountItem} + ref={accountsFlashListRef} /> ); }; @@ -288,12 +368,10 @@ export const BaseWalletDetails = ({ testID={WalletDetailsIds.ACCOUNTS_LIST} > {renderAccountsList()} - {keyringId && renderAddAccountItem()} {children} - {keyringId && ( { paddingLeft: 16, paddingRight: 16, }, - addAccountBox: { - backgroundColor: colors.background.alternative, - borderRadius: 8, - padding: 16, - }, firstAccountBox: { borderTopLeftRadius: 8, borderTopRightRadius: 8, @@ -112,6 +107,42 @@ const styleSheet = (params: { theme: Theme }) => { borderTopRightRadius: 20, backgroundColor: colors.background.default, }, + addAccountItem: { + backgroundColor: colors.background.alternative, + borderRadius: 8, + padding: 16, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginTop: 8, + }, + addAccountButton: { + width: '100%', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-start', + }, + addAccountItemDisabled: { + opacity: 0.5, + }, + addAccountIconContainer: { + backgroundColor: colors.background.muted, + borderRadius: 8, + padding: 8, + marginRight: 16, + width: 32, + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + addAccountText: { + color: colors.text.alternative, + flex: 1, + }, + addAccountButtonText: { + color: colors.primary.default, + fontWeight: '500', + }, }); }; diff --git a/app/components/Views/MultichainAccounts/sheets/EditAccountName/EditAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditAccountName/EditAccountName.tsx index d4978f8abab..18b3feb0e5b 100644 --- a/app/components/Views/MultichainAccounts/sheets/EditAccountName/EditAccountName.tsx +++ b/app/components/Views/MultichainAccounts/sheets/EditAccountName/EditAccountName.tsx @@ -30,14 +30,14 @@ import { TextInput } from 'react-native'; import { EditAccountNameIds } from '../../../../../../e2e/selectors/MultichainAccounts/EditAccountName.selectors'; interface RootNavigationParamList extends ParamListBase { - MultichainEditAccountName: { + EditMultichainAccountName: { account: InternalAccount; }; } type EditAccountNameRouteProp = RouteProp< RootNavigationParamList, - 'MultichainEditAccountName' + 'EditMultichainAccountName' >; export const EditAccountName = () => { diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts new file mode 100644 index 00000000000..d334c22c8b5 --- /dev/null +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.styles.ts @@ -0,0 +1,35 @@ +import { Theme } from '../../../../../util/theme/models'; +import { StyleSheet } from 'react-native'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + marginBottom: 36, + paddingLeft: 24, + paddingRight: 24, + }, + input: { + borderRadius: 8, + borderWidth: 2, + width: '100%', + borderColor: colors.border.default, + padding: 10, + height: 40, + color: colors.text.default, + }, + saveButton: { + flex: 1, + marginLeft: 8, + marginRight: 8, + }, + footer: { + paddingLeft: 16, + paddingRight: 16, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx new file mode 100644 index 00000000000..2ed3899befa --- /dev/null +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.test.tsx @@ -0,0 +1,237 @@ +import React from 'react'; +import { fireEvent } from '@testing-library/react-native'; +import { EditMultichainAccountName } from './EditMultichainAccountName'; +import { strings } from '../../../../../../locales/i18n'; +import { EditAccountNameIds } from '../../../../../../e2e/selectors/MultichainAccounts/EditAccountName.selectors'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; + +jest.mock('react-native-safe-area-context', () => { + const inset = { top: 0, right: 0, bottom: 0, left: 0 }; + const frame = { width: 0, height: 0, x: 0, y: 0 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaConsumer: jest + .fn() + .mockImplementation(({ children }) => children(inset)), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + useSafeAreaFrame: jest.fn().mockImplementation(() => frame), + }; +}); + +const mockGoBack = jest.fn(); +const mockAccountGroup = { + id: 'entropy:wallet1/0', + metadata: { name: 'Test Account Group' }, +}; +const mockRoute = { + params: { + accountGroup: mockAccountGroup, + }, +}; +const mockUseRoute = jest.fn().mockReturnValue(mockRoute); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + goBack: () => mockGoBack(), + }), + useRoute: () => mockUseRoute(), +})); + +const mockSetAccountGroupName = jest.fn(); + +jest.mock('../../../../../core/Engine', () => ({ + context: { + AccountTreeController: { + setAccountGroupName: (groupId: string, name: string) => + mockSetAccountGroupName(groupId, name), + }, + }, +})); + +describe('EditMultichainAccountName', () => { + const render = () => + renderWithProvider( + + + , + ); + + beforeEach(() => { + jest.clearAllMocks(); + mockSetAccountGroupName.mockReset(); + mockUseRoute.mockReturnValue(mockRoute); + }); + + describe('rendering', () => { + it('renders correctly with account group information', () => { + const { getByText, getByTestId } = render(); + + expect(getByText('Test Account Group')).toBeTruthy(); + expect( + getByText( + strings('multichain_accounts.edit_account_name.account_name'), + ), + ).toBeTruthy(); + expect( + getByText( + strings('multichain_accounts.edit_account_name.confirm_button'), + ), + ).toBeTruthy(); + expect( + getByTestId(EditAccountNameIds.EDIT_ACCOUNT_NAME_CONTAINER), + ).toBeTruthy(); + }); + + it('displays account group name input with current name', () => { + const { getByTestId } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + expect(nameInput).toBeTruthy(); + expect(nameInput.props.value).toBe('Test Account Group'); + }); + }); + + describe('user interactions', () => { + it('handles account group name input changes', () => { + const { getByTestId } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + fireEvent.changeText(nameInput, 'New Group Name'); + + expect(nameInput.props.value).toBe('New Group Name'); + }); + + it('navigates back when back button is pressed', () => { + const { getByRole } = render(); + + const backButton = getByRole('button'); + fireEvent.press(backButton); + + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + + it('saves account group name when save button is pressed', () => { + const { getByTestId } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + const saveButton = getByTestId(EditAccountNameIds.SAVE_BUTTON); + + fireEvent.changeText(nameInput, 'Updated Group Name'); + fireEvent.press(saveButton); + + expect(mockSetAccountGroupName).toHaveBeenCalledWith( + mockAccountGroup.id, + 'Updated Group Name', + ); + expect(mockGoBack).toHaveBeenCalledTimes(1); + }); + }); + + describe('validation', () => { + it('shows error when account group name is empty', () => { + const { getByTestId, getByText } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + const saveButton = getByTestId(EditAccountNameIds.SAVE_BUTTON); + + fireEvent.changeText(nameInput, ''); + fireEvent.press(saveButton); + + expect(mockSetAccountGroupName).not.toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(getByText('Account name cannot be empty')).toBeTruthy(); + }); + + it('shows error when account group name is only whitespace', () => { + const { getByTestId, getByText } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + const saveButton = getByTestId(EditAccountNameIds.SAVE_BUTTON); + + fireEvent.changeText(nameInput, ' '); + fireEvent.press(saveButton); + + expect(mockSetAccountGroupName).not.toHaveBeenCalled(); + expect(mockGoBack).not.toHaveBeenCalled(); + expect(getByText('Account name cannot be empty')).toBeTruthy(); + }); + }); + + describe('error handling', () => { + it('handles save error and displays error message', () => { + mockSetAccountGroupName.mockImplementation(() => { + throw new Error('Duplicate name'); + }); + + const { getByTestId, getByText } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + const saveButton = getByTestId(EditAccountNameIds.SAVE_BUTTON); + + fireEvent.changeText(nameInput, 'Duplicate Group Name'); + fireEvent.press(saveButton); + + expect(getByText('Failed to edit account name')).toBeTruthy(); + }); + + it('clears error when input changes after error', () => { + mockSetAccountGroupName.mockImplementation(() => { + throw new Error('Duplicate name'); + }); + + const { getByTestId, getByText, queryByText } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + const saveButton = getByTestId(EditAccountNameIds.SAVE_BUTTON); + + // First, trigger an error + fireEvent.changeText(nameInput, 'Duplicate Group Name'); + fireEvent.press(saveButton); + expect(getByText('Failed to edit account name')).toBeTruthy(); + + // Then, change the input to clear the error + fireEvent.changeText(nameInput, 'New Name'); + expect(queryByText('Failed to edit account name')).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('handles long account group names correctly', () => { + const longNameGroup = { + id: 'entropy:wallet1/1', + metadata: { + name: 'Very Long Account Group Name That Should Still Work Correctly', + }, + }; + + mockUseRoute.mockReturnValue({ + params: { accountGroup: longNameGroup }, + }); + + const { getByTestId } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + expect(nameInput.props.value).toBe( + 'Very Long Account Group Name That Should Still Work Correctly', + ); + }); + + it('handles account group with empty name', () => { + const emptyNameGroup = { + id: 'entropy:wallet1/2', + metadata: { name: '' }, + }; + + mockUseRoute.mockReturnValue({ + params: { accountGroup: emptyNameGroup }, + }); + + const { getByTestId } = render(); + + const nameInput = getByTestId(EditAccountNameIds.ACCOUNT_NAME_INPUT); + expect(nameInput.props.value).toBe(''); + }); + }); +}); diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx new file mode 100644 index 00000000000..e3739ca33d2 --- /dev/null +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/EditMultichainAccountName.tsx @@ -0,0 +1,146 @@ +import React, { useCallback, useState, useRef } from 'react'; +import { useSelector } from 'react-redux'; +import BottomSheet, { + BottomSheetRef, +} from '../../../../../component-library/components/BottomSheets/BottomSheet'; +import BottomSheetHeader from '../../../../../component-library/components/BottomSheets/BottomSheetHeader'; +import { strings } from '../../../../../../locales/i18n'; +import Engine from '../../../../../core/Engine'; +import { + ParamListBase, + RouteProp, + useNavigation, + useRoute, +} from '@react-navigation/native'; +import Text, { + TextColor, +} from '../../../../../component-library/components/Texts/Text'; +import { Box } from '../../../../UI/Box/Box'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { ButtonProps } from '../../../../../component-library/components/Buttons/Button/Button.types'; +import styleSheet from './EditMultichainAccountName.styles'; +import { useStyles } from '../../../../hooks/useStyles'; +import { useTheme } from '../../../../../util/theme'; +import { TextInput } from 'react-native'; +import { EditAccountNameIds } from '../../../../../../e2e/selectors/MultichainAccounts/EditAccountName.selectors'; +import { AccountGroupObject } from '@metamask/account-tree-controller'; +import { RootState } from '../../../../../reducers'; +import { selectAccountGroupById } from '../../../../../selectors/multichainAccounts/accountTreeController'; + +interface RootNavigationParamList extends ParamListBase { + EditMultichainAccountName: { + accountGroup: AccountGroupObject; + }; +} + +type EditMultichainAccountNameRouteProp = RouteProp< + RootNavigationParamList, + 'EditMultichainAccountName' +>; + +export const EditMultichainAccountName = () => { + const { styles } = useStyles(styleSheet, {}); + const { colors, themeAppearance } = useTheme(); + const route = useRoute(); + const { accountGroup: initialAccountGroup } = route.params; + const navigation = useNavigation(); + const sheetRef = useRef(null); + + const accountGroupFromSelector = useSelector((state: RootState) => + initialAccountGroup + ? selectAccountGroupById(state, initialAccountGroup.id) + : undefined, + ); + + const accountGroup = accountGroupFromSelector || initialAccountGroup; + + const initialName = accountGroup?.metadata?.name || ''; + + const [accountName, setAccountName] = useState(initialName); + const [error, setError] = useState(null); + + const handleAccountNameChange = useCallback(() => { + // Validate that account name is not empty + if (!accountName || accountName.trim() === '') { + setError( + strings('multichain_accounts.edit_account_name.error_empty_name'), + ); + return; + } + + //TODO: Validate that account name is not duplicate after it's added to the AccountTreeController + + try { + const { AccountTreeController } = Engine.context; + AccountTreeController.setAccountGroupName(accountGroup.id, accountName); + navigation.goBack(); + } catch { + setError(strings('multichain_accounts.edit_account_name.error')); + } + }, [accountName, accountGroup, navigation]); + + const handleOnBack = useCallback(() => { + navigation.goBack(); + }, [navigation]); + + const handleOnClose = useCallback(() => { + // Close the entire modal stack by going back to the parent + navigation.dangerouslyGetParent()?.goBack(); + }, [navigation]); + + const saveButtonProps: ButtonProps = { + variant: ButtonVariants.Primary, + label: strings('multichain_accounts.edit_account_name.confirm_button'), + size: ButtonSize.Lg, + onPress: handleAccountNameChange, + style: styles.saveButton, + testID: EditAccountNameIds.SAVE_BUTTON, + }; + + return ( + + + {accountGroup?.metadata?.name || 'Account Group'} + + + + {strings('multichain_accounts.edit_account_name.account_name')} + + { + setAccountName(newName); + // Clear error when user starts typing + if (error) { + setError(null); + } + }} + placeholder={initialName} + placeholderTextColor={colors.text.muted} + spellCheck={false} + keyboardAppearance={themeAppearance} + autoCapitalize="none" + autoFocus + editable + /> + {error && {error}} + + + + ); +}; diff --git a/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/index.ts b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/index.ts new file mode 100644 index 00000000000..cfe2a3e019c --- /dev/null +++ b/app/components/Views/MultichainAccounts/sheets/EditMultichainAccountName/index.ts @@ -0,0 +1 @@ +export { EditMultichainAccountName } from './EditMultichainAccountName'; diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx index baf1d830e05..0f620c12369 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.test.tsx @@ -8,6 +8,7 @@ import Engine from '../../../../../core/Engine'; import Routes from '../../../../../constants/navigation/Routes'; import { MULTICHAIN_ACCOUNT_ACTIONS_ACCOUNT_DETAILS, + MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES, } from './MultichainAccountActions.testIds'; @@ -92,7 +93,7 @@ describe('MultichainAccountActions', () => { const { getByText } = renderWithProvider(); expect(getByText('Account Details')).toBeTruthy(); - // expect(getByText('Rename account')).toBeTruthy(); // TODO: Uncomment when account group renaming is supported + expect(getByText('Rename account')).toBeTruthy(); expect(getByText('Addresses')).toBeTruthy(); }); @@ -102,7 +103,7 @@ describe('MultichainAccountActions', () => { expect( getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_ACCOUNT_DETAILS), ).toBeTruthy(); - // expect(getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME)).toBeTruthy(); // TODO: Uncomment when account group renaming is supported + expect(getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME)).toBeTruthy(); expect(getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES)).toBeTruthy(); }); @@ -114,6 +115,7 @@ describe('MultichainAccountActions', () => { ); accountDetailsButton.props.onPress(); + expect(mockGoBack).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_GROUP_DETAILS, { @@ -128,6 +130,7 @@ describe('MultichainAccountActions', () => { const addressesButton = getByTestId(MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES); addressesButton.props.onPress(); + expect(mockGoBack).toHaveBeenCalled(); expect(mockNavigate).toHaveBeenCalledWith( Routes.MULTICHAIN_ACCOUNTS.ADDRESS_LIST, { @@ -136,4 +139,20 @@ describe('MultichainAccountActions', () => { }, ); }); + + it('navigates to edit account name when rename account button is pressed', () => { + const { getByTestId } = renderWithProvider(); + + const renameAccountButton = getByTestId( + MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, + ); + renameAccountButton.props.onPress(); + + expect(mockNavigate).toHaveBeenCalledWith( + Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME, + { + accountGroup: mockAccountGroup, + }, + ); + }); }); diff --git a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx index 4f87dbb34f6..f7aca62b33f 100644 --- a/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx +++ b/app/components/Views/MultichainAccounts/sheets/MultichainAccountActions/MultichainAccountActions.tsx @@ -6,6 +6,7 @@ import { ParamListBase, useRoute, } from '@react-navigation/native'; +import { useSelector } from 'react-redux'; import { AccountGroupObject } from '@metamask/account-tree-controller'; import BottomSheet, { @@ -14,16 +15,34 @@ import BottomSheet, { import AccountAction from '../../../AccountAction/AccountAction'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; import { useStyles } from '../../../../../component-library/hooks'; +import { RootState } from '../../../../../reducers'; +import { selectAccountGroupById } from '../../../../../selectors/multichainAccounts/accountTreeController'; import { strings } from '../../../../../../locales/i18n'; import Routes from '../../../../../constants/navigation/Routes'; import styleSheet from './MultichainAccountActions.styles'; import { MULTICHAIN_ACCOUNT_ACTIONS_ACCOUNT_DETAILS, - // MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, + MULTICHAIN_ACCOUNT_ACTIONS_EDIT_NAME, MULTICHAIN_ACCOUNT_ACTIONS_ADDRESSES, } from './MultichainAccountActions.testIds'; import { createAddressListNavigationDetails } from '../../AddressList/AddressList'; +import { createNavigationDetails } from '../../../../../util/navigation/navUtils'; + +export const createAccountGroupDetailsNavigationDetails = + createNavigationDetails<{ + accountGroup: AccountGroupObject; + }>(Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_GROUP_DETAILS); + +export const createEditAccountNameNavigationDetails = createNavigationDetails<{ + accountGroup: AccountGroupObject; +}>(Routes.SHEET.MULTICHAIN_ACCOUNT_DETAILS.EDIT_ACCOUNT_NAME); + +export const createMultichainAccountDetailActionsModalNavigationDetails = + createNavigationDetails<{ + screen: string; + params: { accountGroup: AccountGroupObject }; + }>(Routes.MODAL.MULTICHAIN_ACCOUNT_DETAIL_ACTIONS); interface MultichainAccountActionsParams { accountGroup: AccountGroupObject; @@ -31,22 +50,40 @@ interface MultichainAccountActionsParams { const MultichainAccountActions = () => { const route = useRoute>(); - const { accountGroup } = route.params as MultichainAccountActionsParams; + const { accountGroup: initialAccountGroup } = + route.params as MultichainAccountActionsParams; + const { id } = initialAccountGroup; + + const accountGroup = + useSelector((state: RootState) => selectAccountGroupById(state, id)) || + initialAccountGroup; + const { styles } = useStyles(styleSheet, {}); const sheetRef = React.useRef(null); - const { navigate } = useNavigation(); + const { navigate, goBack } = useNavigation(); const goToAccountDetails = useCallback(() => { - sheetRef.current?.onCloseBottomSheet(() => { - navigate(Routes.MULTICHAIN_ACCOUNTS.ACCOUNT_GROUP_DETAILS, { + // Close the modal and navigate to account details + goBack(); + navigate( + ...createAccountGroupDetailsNavigationDetails({ accountGroup, - }); - }); - }, [navigate, accountGroup]); + }), + ); + }, [navigate, goBack, accountGroup]); - // const goToEditAccountName = useCallback(() => null, []); // TODO: To be implemented + const goToEditAccountName = useCallback(() => { + // Navigate to edit account name sheet within the same modal + navigate( + ...createEditAccountNameNavigationDetails({ + accountGroup, + }), + ); + }, [navigate, accountGroup]); const goToAddresses = useCallback(() => { + // Close the modal and navigate to address list + goBack(); navigate( ...createAddressListNavigationDetails({ groupId: accountGroup.id, @@ -55,7 +92,7 @@ const MultichainAccountActions = () => { }`, }), ); - }, [accountGroup.id, accountGroup.metadata.name, navigate]); + }, [accountGroup.id, accountGroup.metadata.name, navigate, goBack]); return ( @@ -67,14 +104,13 @@ const MultichainAccountActions = () => { testID={MULTICHAIN_ACCOUNT_ACTIONS_ACCOUNT_DETAILS} style={styles.accountAction} /> - {/* TODO: Uncomment when account group renaming is supported */} + /> justifyContent: 'space-between', gap: 8, }, + networkSelectorNetworkNameLabel: { + color: colors.text.default, + }, resolvedInput: { ...fontStyles.normal, fontSize: 10, @@ -199,6 +202,8 @@ class ContactForm extends PureComponent { sheetRef = React.createRef(); + validationTimeoutId = null; + updateNavBar = () => { const { navigation, route } = this.props; const colors = this.context.colors || mockTheme.colors; @@ -253,6 +258,12 @@ class ContactForm extends PureComponent { this.updateNavBar(); }; + componentWillUnmount = () => { + if (this.validationTimeoutId) { + clearTimeout(this.validationTimeoutId); + } + }; + onEdit = () => { const { navigation } = this.props; const { editable } = this.state; @@ -298,8 +309,21 @@ class ContactForm extends PureComponent { }; onChangeAddress = (address) => { - this.validateAddressOrENSFromInput(address); - this.setState({ address }); + this.setState({ + address, + toEnsName: null, + toEnsAddress: null, + addressError: null, + addressReady: false, + }); + + if (this.validationTimeoutId) { + clearTimeout(this.validationTimeoutId); + } + + this.validationTimeoutId = setTimeout(() => { + this.validateAddressOrENSFromInput(address); + }, 300); }; onChangeMemo = (memo) => { @@ -576,7 +600,9 @@ class ContactForm extends PureComponent { chainId: contactChainId || this.props.chainId, })} /> - {networkName} + + {networkName} + {!!editable && ( { + await Gestures.tap(this.confirmButtonText, { + elemDescription: 'Confirm Button', + waitForElementToDisappear: true, + }); + } + async tapConnectButton(): Promise { await Gestures.waitAndTap(this.connectButtonText, { elemDescription: 'Connect Button', diff --git a/e2e/pages/MultichainAccounts/EditAccountName.ts b/e2e/pages/MultichainAccounts/EditAccountName.ts index fe17af4fd81..dd3b9c387b9 100644 --- a/e2e/pages/MultichainAccounts/EditAccountName.ts +++ b/e2e/pages/MultichainAccounts/EditAccountName.ts @@ -21,7 +21,6 @@ class EditAccountName { await Gestures.typeText(this.accountNameInput, newName, { elemDescription: 'Account Name Input in Edit Account Name', hideKeyboard: true, - delay: 1000, }); } diff --git a/e2e/pages/wallet/WalletView.ts b/e2e/pages/wallet/WalletView.ts index 26b0ea8607f..ca14d76952b 100644 --- a/e2e/pages/wallet/WalletView.ts +++ b/e2e/pages/wallet/WalletView.ts @@ -110,6 +110,10 @@ class WalletView { return Matchers.getElementByID(WalletViewSelectorsIDs.TOKEN_NETWORK_FILTER); } + get sortButton(): DetoxElement { + return Matchers.getElementByID(WalletViewSelectorsIDs.SORT_BUTTON); + } + get sortBy(): DetoxElement { return Matchers.getElementByID(WalletViewSelectorsIDs.SORT_BY); } @@ -334,7 +338,7 @@ class WalletView { } async tapSortBy(): Promise { - await Gestures.waitAndTap(this.sortBy, { + await Gestures.waitAndTap(this.sortButton, { elemDescription: 'Sort By', }); } diff --git a/e2e/selectors/wallet/WalletView.selectors.ts b/e2e/selectors/wallet/WalletView.selectors.ts index 78ae70ccbe1..04c9e65b6d1 100644 --- a/e2e/selectors/wallet/WalletView.selectors.ts +++ b/e2e/selectors/wallet/WalletView.selectors.ts @@ -33,6 +33,7 @@ export const WalletViewSelectorsIDs = { NAVBAR_ADDRESS_COPY_BUTTON: 'navbar-address-copy-button', SORT_DECLINING_BALANCE: 'sort-declining-balance', SORT_ALPHABETICAL: 'sort-alphabetical', + SORT_BUTTON: 'token-sort-button', SORT_BY: 'sort-by', NAVBAR_NETWORK_PICKER: 'network-avatar-picker', TOKEN_NETWORK_FILTER: 'token-network-filter', diff --git a/e2e/specs/quarantine/asset-sort.failing.ts b/e2e/specs/assets/asset-sort.spec.ts similarity index 93% rename from e2e/specs/quarantine/asset-sort.failing.ts rename to e2e/specs/assets/asset-sort.spec.ts index 5d7ba0b799d..a322f41e73c 100644 --- a/e2e/specs/quarantine/asset-sort.failing.ts +++ b/e2e/specs/assets/asset-sort.spec.ts @@ -11,8 +11,6 @@ import ConfirmAddAssetView from '../../pages/wallet/ImportTokenFlow/ConfirmAddAs import Tenderly from '../../tenderly'; import { CustomNetworks } from '../../resources/networks.e2e'; import ImportTokensView from '../../pages/wallet/ImportTokenFlow/ImportTokensView'; -import TestHelpers from '../../helpers'; -import { getFixturesServerPort } from '../../framework/fixtures/FixtureUtils'; import { MockApiEndpoint } from '../../framework'; import { Mockttp } from 'mockttp'; import { setupMockRequest } from '../../api-mocking/helpers/mockHelpers'; @@ -133,13 +131,6 @@ describe(RegressionAssets('Import Tokens'), () => { await loginToApp(); await WalletView.tapSortBy(); await SortModal.tapSortAlphabetically(); - // Relaunching the app since the tree is not re-rendered with FlashList v2. - await device.terminateApp(); - await TestHelpers.launchApp({ - launchArgs: { fixtureServerPort: `${getFixturesServerPort()}` }, - }); - await loginToApp(); - const tokens = (await WalletView.getTokensInWallet()) as IndexableNativeElement; const tokensAttributes = await tokens.getAttributes(); diff --git a/e2e/specs/confirmations/send-erc20-with-dapp.spec.ts b/e2e/specs/confirmations/send-erc20-with-dapp.spec.ts index 990474f13d5..e6c7344aa35 100644 --- a/e2e/specs/confirmations/send-erc20-with-dapp.spec.ts +++ b/e2e/specs/confirmations/send-erc20-with-dapp.spec.ts @@ -64,7 +64,7 @@ describe(SmokeConfirmations('ERC20 tokens'), () => { await TestDApp.tapERC20TransferButton(); // Tap confirm button - await TestDApp.tapConfirmButton(); + await TestDApp.tapConfirmButtonToDisappear(); // Navigate to the activity screen await TabBarComponent.tapActivity(); diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 8db8922a25f..f6da2f95c8f 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -233,6 +233,8 @@ PODS: - ExpoModulesCore - ExpoFont (13.0.4): - ExpoModulesCore + - ExpoHaptics (14.0.1): + - ExpoModulesCore - ExpoKeepAwake (14.0.3): - ExpoModulesCore - ExpoLinking (7.0.5): @@ -2797,6 +2799,7 @@ DEPENDENCIES: - ExpoCrypto (from `../node_modules/expo-crypto/ios`) - ExpoFileSystem (from `../node_modules/expo-file-system/ios`) - ExpoFont (from `../node_modules/expo-font/ios`) + - ExpoHaptics (from `../node_modules/expo-haptics/ios`) - ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`) - ExpoLinking (from `../node_modules/expo-linking/ios`) - ExpoModulesCore (from `../node_modules/expo-modules-core`) @@ -2994,6 +2997,8 @@ EXTERNAL SOURCES: :path: "../node_modules/expo-file-system/ios" ExpoFont: :path: "../node_modules/expo-font/ios" + ExpoHaptics: + :path: "../node_modules/expo-haptics/ios" ExpoKeepAwake: :path: "../node_modules/expo-keep-awake/ios" ExpoLinking: @@ -3276,6 +3281,7 @@ SPEC CHECKSUMS: ExpoCrypto: e97e864c8d7b9ce4a000bca45dddb93544a1b2b4 ExpoFileSystem: 42d363d3b96f9afab980dcef60d5657a4443c655 ExpoFont: f354e926f8feae5e831ec8087f36652b44a0b188 + ExpoHaptics: 8d199b2f33245ea85289ff6c954c7ee7c00a5b5d ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680 ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402 ExpoModulesCore: c25d77625038b1968ea1afefc719862c0d8dd993 diff --git a/jest.config.js b/jest.config.js index 19325ff398c..45650b1086a 100644 --- a/jest.config.js +++ b/jest.config.js @@ -63,6 +63,7 @@ const config = { '^expo-auth-session(/.*)?$': '/app/__mocks__/expo-auth-session.js', '^expo-apple-authentication(/.*)?$': '/app/__mocks__/expo-apple-authentication.js', + '^expo-haptics(/.*)?$': '/app/__mocks__/expo-haptics.js', }, // Disable jest cache cache: false, diff --git a/locales/languages/en.json b/locales/languages/en.json index 19d64425413..717354356e5 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1168,7 +1168,8 @@ "connectionFailed": { "title": "Perps is temporarily offline", "description": "We're working to get it back online soon.", - "retry": "Retry" + "retry": "Retry", + "go_back": "Go Back" }, "networkError": { "title": "Network Error", @@ -5221,7 +5222,10 @@ "quote_details": "Quote Details", "price_impact": "Price Impact", "time": "Time", - "quote_info_content": "This quote offers the best return from our search. It’s based on the swap rate, including bridging fees and a 0.875% MetaMask fee, but not gas fees. Gas fees vary with network activity and transaction complexity.", + "points": "Points", + "points_tooltip": "Points", + "points_tooltip_content": "Estimate of MetaMask Rewards Points you will earn from this trade. Points can take up to 1 hour to be confirmed in your Rewards balance", + "quote_info_content": "This quote offers the best return from our search. It's based on the swap rate, including bridging fees and a 0.875% MetaMask fee, but not gas fees. Gas fees vary with network activity and transaction complexity.", "quote_info_title": "Why we recommend this quote", "see_other_quotes": "See other quotes", "receive_at": "Receive at", @@ -5421,9 +5425,13 @@ }, "edit_account_name": { "title": "Edit Account Name", + "account_name": "Account name", + "confirm_button": "Confirm", "name": "Name", "save_button": "Save", - "error_duplicate_name": "This account name already exists" + "error": "Failed to edit account name", + "error_duplicate_name": "This account name already exists", + "error_empty_name": "Account name cannot be empty" }, "delete_account": { "title": "Remove Account", diff --git a/package.json b/package.json index 142a71a60ce..56a29cff529 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "test:e2e:ios:main:prod": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c ios.sim.main.release", "test:e2e:android:main:prod": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c android.emu.release --headless --record-logs all", "test:e2e:android:run:github:qa-release": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c android.github_ci.release --headless --record-logs all", + "test:e2e:ios-gha:main:prod": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c ios.github_ci.main.release --headless --record-logs all", "test:e2e:ios:flask:prod": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c ios.sim.flask.release", "test:e2e:android:flask:prod": "IS_TEST='true' NODE_OPTIONS='--experimental-vm-modules' detox test -c android.emu.flask.release --headless --record-logs all", "test:e2e:ios:build:main-release": "IS_TEST='true' detox build -c ios.sim.main.release", @@ -220,7 +221,7 @@ "@metamask/app-metadata-controller": "^1.0.0", "@metamask/approval-controller": "^7.1.3", "@metamask/assets-controllers": "^74.3.2", - "@metamask/base-controller": "^8.0.0", + "@metamask/base-controller": "^8.3.0", "@metamask/bitcoin-wallet-snap": "^1.0.0", "@metamask/bridge-controller": "^41.4.0", "@metamask/bridge-status-controller": "^40.2.0", @@ -294,7 +295,7 @@ "@metamask/snaps-rpc-methods": "^13.5.0", "@metamask/snaps-sdk": "^9.3.0", "@metamask/snaps-utils": "^11.5.0", - "@metamask/solana-wallet-snap": "^2.3.6", + "@metamask/solana-wallet-snap": "^2.3.7", "@metamask/solana-wallet-standard": "^0.5.1", "@metamask/stake-sdk": "^3.2.0", "@metamask/swappable-obj-proxy": "^2.1.0", @@ -379,6 +380,7 @@ "expo-build-properties": "~0.13.2", "expo-dev-client": "~5.0.18", "expo-file-system": "~18.0.7", + "expo-haptics": "~14.0.1", "expo-sensors": "~14.0.2", "fast-equals": "^5.2.2", "fuse.js": "3.4.4", diff --git a/scripts/run-e2e-tags-gha.sh b/scripts/run-e2e-tags-gha.sh index cf772f041fa..e1dbed91d6e 100755 --- a/scripts/run-e2e-tags-gha.sh +++ b/scripts/run-e2e-tags-gha.sh @@ -64,16 +64,33 @@ echo -e "\nπŸš€ Running matching tests for split $SPLIT_NUMBER..." # Join array elements with spaces to pass to test command TEST_FILES="${split_files[*]}" +# Determine platform and environment +IS_IOS_WORKFLOW="false" +IS_GITHUB_CI="false" + if [[ "$BITRISE_TRIGGERED_WORKFLOW_ID" == *"ios"* ]]; then - echo "Detected iOS workflow" + IS_IOS_WORKFLOW="true" +fi + +if [[ -n "${GITHUB_CI:-}" ]]; then + IS_GITHUB_CI="true" +fi + +# Run tests based on platform and environment +if [[ "$IS_IOS_WORKFLOW" == "true" ]] && [[ "$IS_GITHUB_CI" == "true" ]]; then + echo "🍎 Running iOS tests on GitHub Actions" + IGNORE_BOXLOGS_DEVELOPMENT="true" \ + yarn test:e2e:ios-gha:$METAMASK_BUILD_TYPE:prod $TEST_FILES +elif [[ "$IS_IOS_WORKFLOW" == "true" ]]; then + echo "🍎 Running iOS tests on Bitrise" IGNORE_BOXLOGS_DEVELOPMENT="true" \ yarn test:e2e:ios:$METAMASK_BUILD_TYPE:prod $TEST_FILES -elif [[ -n "${GITHUB_CI:-}" ]]; then - echo "Detected GitHub Actions workflow - using GitHub CI configuration" +elif [[ "$IS_GITHUB_CI" == "true" ]]; then + echo "πŸ€– Running Android tests on GitHub Actions" IGNORE_BOXLOGS_DEVELOPMENT="true" \ yarn test:e2e:android:run:github:qa-release $TEST_FILES else - echo "Detected Android workflow" + echo "πŸ€– Running Android tests on Bitrise" IGNORE_BOXLOGS_DEVELOPMENT="true" \ yarn test:e2e:android:$METAMASK_BUILD_TYPE:prod $TEST_FILES fi diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 5fe3a0ef9ab..c2416cc3cd6 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -170,18 +170,29 @@ const setupIosTask = { title: 'Install bundler gem', task: async (_, task) => { if (GITHUB_CI) { - return task.skip('Skipping bundler gem installation in GitHub CI.'); + // In GitHub CI, we still need bundler for self-hosted runners + try { + await $`gem install bundler -v 2.5.8`; + } catch (error) { + // If bundler is already installed, continue + if (!error.stderr?.includes('already installed')) { + throw error; + } + } + } else { + await $`gem install bundler -v 2.5.8`; } - await $`gem install bundler -v 2.5.8`; }, }, { title: 'Install gems', task: async (_, task) => { if (GITHUB_CI) { - return task.skip('Skipping gems installation in GitHub CI.'); + // In GitHub CI, install gems for self-hosted runners + await $`yarn gem:bundle:install`; + } else { + await $`yarn gem:bundle:install`; } - await $`yarn gem:bundle:install`; }, }, { diff --git a/yarn.lock b/yarn.lock index 08146975e72..5eaa39297c6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4979,12 +4979,12 @@ "@metamask/utils" "^11.0.1" immer "^9.0.6" -"@metamask/base-controller@^8.0.0", "@metamask/base-controller@^8.0.1", "@metamask/base-controller@^8.1.0", "@metamask/base-controller@^8.2.0": - version "8.2.0" - resolved "https://registry.yarnpkg.com/@metamask/base-controller/-/base-controller-8.2.0.tgz#1b6f0fcdef517013af92c16af0e50de2ad0628e4" - integrity sha512-Wd7W2R1lutAeQsTH5qI2AeICGIXJYCfddVxvrPKDbwhl6pMaD3FgJS27PSCXWu++jKs4ODAXljk+Z0QwnaYNFA== +"@metamask/base-controller@^8.0.0", "@metamask/base-controller@^8.0.1", "@metamask/base-controller@^8.1.0", "@metamask/base-controller@^8.2.0", "@metamask/base-controller@^8.3.0": + version "8.3.0" + resolved "https://registry.yarnpkg.com/@metamask/base-controller/-/base-controller-8.3.0.tgz#d547323192f7e60d37db63ddbc553544bca16a7e" + integrity sha512-DsaQIymoS6c5cDD2sysebZ/8iZwFv1mTyk31r0d8cseyRaf7MxUDj7WUf0RTxGUYiWuOy6RjXT+ySM8IJjhqVQ== dependencies: - "@metamask/messenger" "^0.1.0" + "@metamask/messenger" "^0.2.0" "@metamask/utils" "^11.4.2" immer "^9.0.6" @@ -5538,10 +5538,10 @@ resolved "https://registry.npmjs.org/@metamask/message-signing-snap/-/message-signing-snap-1.1.2.tgz#701ace646077976ba22feda85e7968a1ea4c8642" integrity sha512-TANi87ujo7WwyHubWy0iFoYFIh5t5ztvfZ/OqrA0rdZ2d58+6Bvj89jbi1tFWQPT//azwCF5jwQBnJAIVlAwBQ== -"@metamask/messenger@^0.1.0": - version "0.1.0" - resolved "https://registry.yarnpkg.com/@metamask/messenger/-/messenger-0.1.0.tgz#3ceb310573b8b32c328a92c67da7199564444523" - integrity sha512-UqYOFvPb8CvRML26h/ftrwFhiQiB0VENoRnUlM0Tl9QjGh17un2emyxpsib3c2rdzP5U/VskXH2KlzPBtfFLYw== +"@metamask/messenger@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@metamask/messenger/-/messenger-0.2.0.tgz#2954cc3d893532b11766516ee284997557de8c65" + integrity sha512-Ktv9aselR1KH2Nnpj7kulragl4kdCUtLZcuQeAxClV7atn6HmJBmuIYLlTawnRwr8guiKqscAs7EiQQvU0DLiA== "@metamask/metamask-eth-abis@3.1.1", "@metamask/metamask-eth-abis@^3.1.1": version "3.1.1" @@ -6146,10 +6146,10 @@ ses "^1.14.0" validate-npm-package-name "^5.0.0" -"@metamask/solana-wallet-snap@^2.3.6": - version "2.3.6" - resolved "https://registry.npmjs.org/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.6.tgz#5fe6d20d87f07d9bfe0c7a74fbcab6aa5ef19be9" - integrity sha512-1w2+j2D3Dh7jhp5dAirhZd+/3Q/ExnAgb3+qAPPWCdaHUkYha86EGMXQd3Yqu0IQewEVYwZ4yK+CJ7gYg34d6A== +"@metamask/solana-wallet-snap@^2.3.7": + version "2.3.7" + resolved "https://registry.yarnpkg.com/@metamask/solana-wallet-snap/-/solana-wallet-snap-2.3.7.tgz#23da40bb5762a0dbe3f12b9d89d3cabaa5a57dd3" + integrity sha512-xHu3urjUkfMeXLm5SEl2CoBSXeWsEYIQ5SjipNBYs2ZKhhP1Tapz1OEN+TGSlusIgoWoBKyLtuUY/Xl53Kj3/w== "@metamask/solana-wallet-standard@^0.5.1": version "0.5.1" @@ -20186,6 +20186,11 @@ expo-font@~13.0.4: dependencies: fontfaceobserver "^2.1.0" +expo-haptics@~14.0.1: + version "14.0.1" + resolved "https://registry.yarnpkg.com/expo-haptics/-/expo-haptics-14.0.1.tgz#ff4ead605e33f1917e615c9328af7ac1c34892dc" + integrity sha512-V81FZ7xRUfqM6uSI6FA1KnZ+QpEKnISqafob/xEfcx1ymwhm4V3snuLWWFjmAz+XaZQTqlYa8z3QbqEXz7G63w== + expo-json-utils@~0.14.0: version "0.14.0" resolved "https://registry.yarnpkg.com/expo-json-utils/-/expo-json-utils-0.14.0.tgz#ad3cbbcb4fb22e4d23bf9fb19b611e36758861d2"