From c702a363425ed1fdf3e90b94696a5ff670e6cf81 Mon Sep 17 00:00:00 2001 From: Wei Sun Date: Mon, 30 Mar 2026 16:44:05 -0700 Subject: [PATCH 1/8] feat: create production runway workflows (#27887) ## **Description** - Create a centralized workflow Runway OTA Build Core that decide either to run an OTA update or start a new build based on if there is an OTA version change - All 4 workflows use Runway OTA Build: runway-android-production-workflow.yml, runway-android-rc-workflow.yml, runway-ios-rc-workflow.yml and runway-ios-production-workflow.yml - Create a workflow to tag branch if it's an OTA update: runway-create-ota-production-tag.yml Note: all ios workflows (rc and production) will not skip version bump but Android will. That's because we only want to bump version once. runway-android-rc-workflow (build): https://github.com/MetaMask/metamask-mobile/actions/runs/23557585480 runway-ios-rc-workflow (build): https://github.com/MetaMask/metamask-mobile/actions/runs/23557559865 runway-android-rc-workflow (OTA): https://github.com/MetaMask/metamask-mobile/actions/runs/23561069878 runway-ios-rc-workflow (OTA): https://github.com/MetaMask/metamask-mobile/actions/runs/23561062779 ## **Changelog** CHANGELOG entry: Added runway production workflows ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Changes GitHub Actions release automation for OTA updates, builds, and tagging; mistakes could misroute production OTAs, skip version bumps, or create incorrect tags/artifacts. > > **Overview** > Adds dedicated Runway `workflow_dispatch` entrypoints for **iOS/Android RC and production** that call a reusable `runway-ota-build-core.yml`, wiring platform/channel/build-name differences and enabling TestFlight upload for iOS. > > Refactors `runway-ota-build-core.yml` to be `workflow_call`-driven with new inputs (e.g., `platform`, `ota_channel`, `build_name`, `skip_version_bump`, `upload_testflight`) and updates OTA dispatch to use `actions/github-script@v7` plus parameterized channel/platform and artifact naming. > > Introduces reusable `runway-create-ota-production-tag.yml` to create an annotated `v*` tag after a successful **production OTA** (idempotent, refuses to move existing tags), and removes the legacy `runway_android_rc_workflow.yml` in favor of the new structure. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit a54a66485419652ef7fa09d9e8247bd39485180c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../runway-android-production-workflow.yml | 41 +++++ .../workflows/runway-android-rc-workflow.yml | 38 +++++ .../runway-create-ota-production-tag.yml | 69 ++++++++ .../runway-ios-production-workflow.yml | 42 +++++ .github/workflows/runway-ios-rc-workflow.yml | 38 +++++ ...workflow.yml => runway-ota-build-core.yml} | 96 ++++++++--- .../workflows/runway_android_rc_workflow.yml | 151 ------------------ 7 files changed, 303 insertions(+), 172 deletions(-) create mode 100644 .github/workflows/runway-android-production-workflow.yml create mode 100644 .github/workflows/runway-android-rc-workflow.yml create mode 100644 .github/workflows/runway-create-ota-production-tag.yml create mode 100644 .github/workflows/runway-ios-production-workflow.yml create mode 100644 .github/workflows/runway-ios-rc-workflow.yml rename .github/workflows/{runway_ios_rc_workflow.yml => runway-ota-build-core.yml} (71%) delete mode 100644 .github/workflows/runway_android_rc_workflow.yml diff --git a/.github/workflows/runway-android-production-workflow.yml b/.github/workflows/runway-android-production-workflow.yml new file mode 100644 index 000000000000..fdac5745de22 --- /dev/null +++ b/.github/workflows/runway-android-production-workflow.yml @@ -0,0 +1,41 @@ +############################################################################################## +# +# Runway Android Production Workflow +# +# Triggered from Runway to either: +# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or +# - Build the production mobile app (when there is no OTA version bump). +# +# When triggering workflow_dispatch, select the correct branch (e.g. main or release). +# Version bump: skipped — run Runway iOS Production first on the same branch for the bump. +# +############################################################################################## +name: Runway Android Production + +on: + workflow_dispatch: + inputs: + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the "Use workflow from" UI. + required: false + type: string + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + runway-production: + uses: ./.github/workflows/runway-ota-build-core.yml + with: + platform: android + source_branch: ${{ inputs.source_branch }} + ota_channel: production + build_name: main-prod + create_production_ota_tag: true + skip_version_bump: true + secrets: inherit diff --git a/.github/workflows/runway-android-rc-workflow.yml b/.github/workflows/runway-android-rc-workflow.yml new file mode 100644 index 000000000000..04fabffcf435 --- /dev/null +++ b/.github/workflows/runway-android-rc-workflow.yml @@ -0,0 +1,38 @@ +############################################################################################## +# +# Runway Android RC Workflow +# +# Triggered from Runway to either: +# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts line 9 is bumped), or +# - Build the mobile app (when there is no OTA version bump). +# +# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). +# Version bump: skipped here — run Runway iOS RC first on the same branch so it performs the bump. +# +############################################################################################## +name: Runway Android RC + +on: + workflow_dispatch: + inputs: + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the "Use workflow from" UI. + required: false + type: string + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + runway-rc: + uses: ./.github/workflows/runway-ota-build-core.yml + with: + platform: android + source_branch: ${{ inputs.source_branch }} + skip_version_bump: true + secrets: inherit diff --git a/.github/workflows/runway-create-ota-production-tag.yml b/.github/workflows/runway-create-ota-production-tag.yml new file mode 100644 index 000000000000..6119c040b7af --- /dev/null +++ b/.github/workflows/runway-create-ota-production-tag.yml @@ -0,0 +1,69 @@ +############################################################################################## +# +# Reusable: create SemVer release tag after production OTA (idempotent). +# +# Callers: runway_*_production_workflow.yml after trigger-ota succeeds. +# Skips if the tag already points at the checked-out commit; fails if the tag exists elsewhere. +# +############################################################################################## +name: Create OTA production release tag + +on: + workflow_call: + inputs: + tag_name: + description: 'Annotated tag to create; must match OTA_VERSION (app/constants/ota.ts) / decide ota_version' + required: true + type: string + checkout_ref: + description: 'Branch or ref that received the OTA (same as workflow source)' + required: true + type: string + +permissions: + contents: write + +jobs: + create-tag: + name: Create release tag (production OTA) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ inputs.checkout_ref }} + + - name: Create or skip release tag + env: + TAG_NAME: ${{ inputs.tag_name }} + run: | + set -euo pipefail + if [[ -z "${TAG_NAME}" ]]; then + echo '::error::tag_name is empty; cannot create release tag' + exit 1 + fi + if [[ ! "${TAG_NAME}" =~ ^v[^[:space:]]+$ ]]; then + echo "::error::tag_name must be non-empty and start with v (no spaces), got: ${TAG_NAME}" + exit 1 + fi + + HEAD_SHA=$(git rev-parse HEAD) + git fetch origin --tags --force 2>/dev/null || true + + if git rev-parse -q --verify "refs/tags/${TAG_NAME}" >/dev/null 2>&1; then + TAG_SHA=$(git rev-parse "${TAG_NAME}^{commit}") + if [[ "${HEAD_SHA}" == "${TAG_SHA}" ]]; then + echo "Tag \`${TAG_NAME}\` already points at this commit (${HEAD_SHA}); skipping create and push." + exit 0 + fi + echo "::error::Tag \`${TAG_NAME}\` already exists at ${TAG_SHA} but HEAD is ${HEAD_SHA}. Refusing to move the tag." + exit 1 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + git tag -a "${TAG_NAME}" -m "Production OTA release ${TAG_NAME}" + git push origin "refs/tags/${TAG_NAME}" + echo "Created and pushed tag \`${TAG_NAME}\` at ${HEAD_SHA}" diff --git a/.github/workflows/runway-ios-production-workflow.yml b/.github/workflows/runway-ios-production-workflow.yml new file mode 100644 index 000000000000..b913cc4d5888 --- /dev/null +++ b/.github/workflows/runway-ios-production-workflow.yml @@ -0,0 +1,42 @@ +############################################################################################## +# +# Runway iOS Production Workflow +# +# Triggered from Runway to either: +# - Push an OTA update to the production channel (when OTA_VERSION is bumped), or +# - Build the production app and upload the IPA to TestFlight (when there is no OTA bump). +# +# When triggering workflow_dispatch, select the correct branch (e.g. main or release). +# Version bump: this workflow bumps; run Android production after on the same branch. +# +############################################################################################## +name: Runway iOS Production + +on: + workflow_dispatch: + inputs: + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the "Use workflow from" UI. + required: false + type: string + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + runway-production: + uses: ./.github/workflows/runway-ota-build-core.yml + with: + platform: ios + source_branch: ${{ inputs.source_branch }} + ota_channel: production + build_name: main-prod + upload_testflight: true + create_production_ota_tag: true + ios_testflight_summary_title: 'Runway iOS Production' + secrets: inherit diff --git a/.github/workflows/runway-ios-rc-workflow.yml b/.github/workflows/runway-ios-rc-workflow.yml new file mode 100644 index 000000000000..d0107b845081 --- /dev/null +++ b/.github/workflows/runway-ios-rc-workflow.yml @@ -0,0 +1,38 @@ +############################################################################################## +# +# Runway iOS RC Workflow +# +# Triggered from Runway to either: +# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts line 9 is bumped), or +# - Build the mobile app and upload the IPA to TestFlight (when there is no OTA version bump). +# +# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). +# Version bump: this workflow bumps the repo build number; run Android RC after so it can skip bump. +# +############################################################################################## +name: Runway iOS RC + +on: + workflow_dispatch: + inputs: + source_branch: + description: >- + Optional branch, tag, or SHA (Build workflow source_branch). + Empty uses the branch selected in the "Use workflow from" UI. + required: false + type: string + +permissions: + contents: write # required by build.yml (update-build-version job) + pull-requests: read + actions: write + id-token: write # required by build.yml + +jobs: + runway-rc: + uses: ./.github/workflows/runway-ota-build-core.yml + with: + platform: ios + source_branch: ${{ inputs.source_branch }} + upload_testflight: true + secrets: inherit diff --git a/.github/workflows/runway_ios_rc_workflow.yml b/.github/workflows/runway-ota-build-core.yml similarity index 71% rename from .github/workflows/runway_ios_rc_workflow.yml rename to .github/workflows/runway-ota-build-core.yml index d859f53a641f..bf427bb83064 100644 --- a/.github/workflows/runway_ios_rc_workflow.yml +++ b/.github/workflows/runway-ota-build-core.yml @@ -1,25 +1,59 @@ ############################################################################################## # -# Runway iOS RC Workflow +# Runway OTA / build core (reusable) # -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts line 9 is bumped), or -# - Build the mobile app and upload the IPA to TestFlight (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). +# Shared logic for Runway: OTA bump detection, push-eas-update dispatch, or build.yml. +# Callers: iOS Runway workflows bump once (skip_version_bump false); Android pass skip_version_bump true. # ############################################################################################## -name: Runway iOS RC +name: Runway OTA Build Core on: - workflow_dispatch: + workflow_call: inputs: + platform: + description: 'Target platform passed to push-eas-update and build.yml (android or ios)' + required: true + type: string source_branch: description: >- Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. + Empty uses the branch selected in the caller workflow_dispatch "Use workflow from" UI. + required: false + type: string + default: '' + ota_channel: + description: 'push-eas-update channel input (e.g. rc, production)' + required: false + type: string + default: rc + build_name: + description: 'build.yml build_name (e.g. main-rc, main-prod)' + required: false + type: string + default: main-rc + upload_testflight: + description: 'If true and platform is ios, upload IPA to TestFlight after trigger-build' + required: false + type: boolean + default: false + create_production_ota_tag: + description: 'If true, create OTA release tag after production trigger-ota (callers: *production* only)' + required: false + type: boolean + default: false + ios_testflight_summary_title: + description: 'Step summary heading when upload_testflight is true' required: false type: string + default: 'Runway iOS RC' + skip_version_bump: + description: >- + If true, build.yml skips update-latest-build-version. Android Runway entry workflows set + true (iOS bumps first); iOS uses default false. + required: false + type: boolean + default: false permissions: contents: write # required by build.yml (update-build-version job) @@ -106,6 +140,8 @@ jobs: needs: decide if: needs.decide.outputs.ota_bump == 'true' runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.release_tag.outputs.release_tag }} steps: - name: Validate PR number run: | @@ -117,7 +153,7 @@ jobs: echo "Using PR #${{ needs.decide.outputs.pr_number }}" - name: Trigger Push OTA Update workflow - uses: actions/github-script@v6 + uses: actions/github-script@v7 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | @@ -131,44 +167,61 @@ jobs: pr_number: '${{ needs.decide.outputs.pr_number }}', base_branch: '${{ needs.decide.outputs.base_ref }}', message: '${{ needs.decide.outputs.ota_version }}', - channel: 'rc', - platform: 'ios' + channel: '${{ inputs.ota_channel }}', + platform: '${{ inputs.platform }}' } }); core.notice(`Triggered Push OTA Update on ${ref} (PR #${{ needs.decide.outputs.pr_number }}, base: ${{ needs.decide.outputs.base_ref }}, message: ${{ needs.decide.outputs.ota_version }})`); + - name: Export release tag for OTA follow-up jobs + id: release_tag + run: | + # Tag name must match OTA_VERSION (app/constants/ota.ts), not package.json semver + echo "release_tag=${{ needs.decide.outputs.ota_version }}" >> "$GITHUB_OUTPUT" + trigger-build: name: Trigger build mobile app needs: decide if: needs.decide.outputs.ota_bump != 'true' uses: ./.github/workflows/build.yml with: - build_name: main-rc - platform: ios - skip_version_bump: false + build_name: ${{ inputs.build_name }} + platform: ${{ inputs.platform }} + skip_version_bump: ${{ inputs.skip_version_bump }} source_branch: ${{ inputs.source_branch || github.ref_name }} upload_to_sentry: true secrets: inherit + create-ota-production-tag: + name: Create OTA production release tag + needs: [trigger-ota] + if: ${{ inputs.create_production_ota_tag == true }} + uses: ./.github/workflows/runway-create-ota-production-tag.yml + with: + tag_name: ${{ needs.trigger-ota.outputs.release_tag }} + checkout_ref: ${{ inputs.source_branch || github.ref_name }} + secrets: inherit + testflight-upload-summary: name: TestFlight upload summary needs: [trigger-build] + if: ${{ inputs.platform == 'ios' && inputs.upload_testflight }} runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch || github.ref }} - name: Display TestFlight upload summary run: | BUILD_VERSION=$(node -p "require('./package.json').version") { - echo "### 📲 TestFlight Upload (Runway iOS RC)" + echo "### 📲 TestFlight Upload (${{ inputs.ios_testflight_summary_title }})" echo "" echo "| Field | Value |" echo "| --- | --- |" echo "| **Ref** | ${{ inputs.source_branch || github.ref_name }} |" - echo "| **Build name** | main-rc |" + echo "| **Build name** | ${{ inputs.build_name }} |" echo "| **Build version** | ${BUILD_VERSION} |" echo "| **TestFlight group** | MetaMask BETA & Release Candidates |" } >> "$GITHUB_STEP_SUMMARY" @@ -176,6 +229,7 @@ jobs: upload-ios-testflight: name: Upload iOS to TestFlight needs: [trigger-build, testflight-upload-summary] + if: ${{ inputs.platform == 'ios' && inputs.upload_testflight }} runs-on: ghcr.io/cirruslabs/macos-runner:sequoia-xl environment: apple steps: @@ -183,7 +237,7 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 - ref: ${{ inputs.source_branch || github.ref_name }} + ref: ${{ inputs.source_branch || github.ref }} - name: Setup Ruby (iOS) uses: ruby/setup-ruby@44511735964dcb71245e7e55f72539531f7bc0eb #v1 @@ -195,7 +249,7 @@ jobs: - name: Download iOS build artifact uses: actions/download-artifact@v4 with: - name: ios-ipa-main-rc + name: ios-ipa-${{ inputs.build_name }} - name: Find IPA path id: ipa @@ -222,7 +276,7 @@ jobs: - name: Upload to TestFlight run: | bash scripts/upload-to-testflight.sh \ - "github_actions_main-rc" \ + "github_actions_${{ inputs.build_name }}" \ "${{ inputs.source_branch || github.ref_name }}" \ "${{ steps.ipa.outputs.path }}" \ "" \ diff --git a/.github/workflows/runway_android_rc_workflow.yml b/.github/workflows/runway_android_rc_workflow.yml deleted file mode 100644 index ec32abaf45e2..000000000000 --- a/.github/workflows/runway_android_rc_workflow.yml +++ /dev/null @@ -1,151 +0,0 @@ -############################################################################################## -# -# Runway Android RC Workflow -# -# Triggered from Runway to either: -# - Push an OTA update (when OTA_VERSION in app/constants/ota.ts line 9 is bumped), or -# - Build the mobile app (when there is no OTA version bump). -# -# When triggering workflow_dispatch, select the release branch (e.g. release/7.71.0). -# -############################################################################################## -name: Runway Android RC - -on: - workflow_dispatch: - inputs: - source_branch: - description: >- - Optional branch, tag, or SHA (Build workflow source_branch). - Empty uses the branch selected in the "Use workflow from" UI. - required: false - type: string - -permissions: - contents: write # required by build.yml (update-build-version job) - pull-requests: read - actions: write - id-token: write # required by build.yml - -jobs: - decide: - name: Check OTA version and resolve inputs - runs-on: ubuntu-latest - outputs: - ota_bump: ${{ steps.decide.outputs.ota_bump }} - base_ref: ${{ steps.decide.outputs.base_ref }} - ota_version: ${{ steps.decide.outputs.ota_version }} - pr_number: ${{ steps.resolve-pr.outputs.pr_number }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - ref: ${{ inputs.source_branch || github.ref }} - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version-file: '.nvmrc' - - - name: Resolve PR number for current branch - id: resolve-pr - run: | - BRANCH="${{ inputs.source_branch || github.ref_name }}" - # Strip refs/heads/ if present - BRANCH="${BRANCH#refs/heads/}" - echo "Resolving PR for branch: $BRANCH (repo: $GITHUB_REPOSITORY)" - - # Try same-repo head first, then owner:branch (required by API when listing pulls) - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "") - if [[ -z "$PR_NUMBER" ]]; then - PR_NUMBER=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$GITHUB_REPOSITORY_OWNER:$BRANCH" --json number --jq '.[0].number' 2>/dev/null || echo "") - fi - - echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" - echo "Branch: $BRANCH, PR number: ${PR_NUMBER:-none}" - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Decide OTA vs build - id: decide - run: | - set -e - # Version from package.json (e.g. 7.70.0) → base ref for OTA workflow is always v{VERSION} - VERSION=$(node -p "require('./package.json').version") - RELEASE_TAG="v${VERSION}" - echo "base_ref=${RELEASE_TAG}" >> "$GITHUB_OUTPUT" - - # Extract OTA_VERSION from line 9 (format: export const OTA_VERSION: string = 'vX.Y.Z';) - extract_ota() { sed -n '9p' "$1" | sed "s/.*'\\([^']*\\)'.*/\1/"; } - - # OTA_VERSION from current ref - CURRENT_OTA=$(extract_ota app/constants/ota.ts) - echo "ota_version=${CURRENT_OTA}" >> "$GITHUB_OUTPUT" - - # Ref to compare against for detecting bump: use release tag if it exists, else main - if git rev-parse "$RELEASE_TAG" >/dev/null 2>&1; then - COMPARE_REF="$RELEASE_TAG" - BASE_OTA=$(git show "${COMPARE_REF}:app/constants/ota.ts" 2>/dev/null | sed -n '9p' | sed "s/.*'\\([^']*\\)'.*/\1/" || echo "") - else - COMPARE_REF="main" - BASE_OTA=$(git show "origin/main:app/constants/ota.ts" 2>/dev/null | sed -n '9p' | sed "s/.*'\\([^']*\\)'.*/\1/" || echo "") - echo "Release tag ${RELEASE_TAG} not found; comparing OTA_VERSION to ${COMPARE_REF} to detect bump" - fi - - if [[ -n "$BASE_OTA" && "$CURRENT_OTA" != "$BASE_OTA" ]]; then - echo "ota_bump=true" >> "$GITHUB_OUTPUT" - echo "OTA_VERSION changed: $BASE_OTA -> $CURRENT_OTA → will trigger OTA update" - else - echo "ota_bump=false" >> "$GITHUB_OUTPUT" - echo "No OTA version bump (base: $BASE_OTA, current: $CURRENT_OTA) → will trigger build" - fi - - trigger-ota: - name: Trigger OTA update - needs: decide - if: needs.decide.outputs.ota_bump == 'true' - runs-on: ubuntu-latest - steps: - - name: Validate PR number - run: | - if [[ -z "${{ needs.decide.outputs.pr_number }}" ]]; then - echo "::error::No PR found for this branch. OTA update requires a PR number." - echo "::error::If you ran the workflow manually (workflow_dispatch), select your release branch in the 'Use workflow from' dropdown (e.g. release/7.71.0), not main." - exit 1 - fi - echo "Using PR #${{ needs.decide.outputs.pr_number }}" - - - name: Trigger Push OTA Update workflow - uses: actions/github-script@v6 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - script: | - const ref = '${{ inputs.source_branch || github.ref_name }}'.replace(/^refs\/heads\//, ''); - await github.rest.actions.createWorkflowDispatch({ - owner: context.repo.owner, - repo: context.repo.repo, - workflow_id: 'push-eas-update.yml', - ref: ref, - inputs: { - pr_number: '${{ needs.decide.outputs.pr_number }}', - base_branch: '${{ needs.decide.outputs.base_ref }}', - message: '${{ needs.decide.outputs.ota_version }}', - channel: 'rc', - platform: 'android' - } - }); - core.notice(`Triggered Push OTA Update on ${ref} (PR #${{ needs.decide.outputs.pr_number }}, base: ${{ needs.decide.outputs.base_ref }}, message: ${{ needs.decide.outputs.ota_version }})`); - - trigger-build: - name: Trigger build mobile app - needs: decide - if: needs.decide.outputs.ota_bump != 'true' - uses: ./.github/workflows/build.yml - with: - build_name: main-rc - platform: android - skip_version_bump: false - source_branch: ${{ inputs.source_branch || github.ref_name }} - upload_to_sentry: true - secrets: inherit From b02b848bb896f7208c89c558c90c977b6d5a1fd1 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Mon, 30 Mar 2026 22:54:29 -0500 Subject: [PATCH 2/8] chore: update CODEOWNERS to @MetaMask/money-movement (#28131) ## **Description** The Ramps lane has been renamed to **Money Movement** on GitHub ([`@MetaMask/money-movement`](https://github.com/orgs/MetaMask/teams/money-movement)). This updates `.github/CODEOWNERS` so review requests and branch-protection ownership resolve to the current org team instead of `@MetaMask/ramp`. **What changed** - Section comment renamed to **Money Movement Team (formerly Ramps)**. - All `@MetaMask/ramp` entries replaced with `@MetaMask/money-movement` (Ramp UI, fiat orders, ramps controller/messengers, selectors, path globs, and Swaps co-owned `parseAmount` / `parseAmount.test.ts`). - Co-ownership comment updated to reference Money Movement alongside Swaps. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: CODEOWNERS ownership update Scenario: No in-app verification required Given this pull request only changes .github/CODEOWNERS When reviewers validate the team slug and paths Then no wallet UI or device manual test is applicable ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Low risk because it only updates `.github/CODEOWNERS` mappings; the main impact is on review routing/branch protection if any paths or the new team slug are incorrect. > > **Overview** > Updates `.github/CODEOWNERS` to reflect the GitHub team rename from `@MetaMask/ramp` to `@MetaMask/money-movement` for Ramp-related paths (UI, fiat orders, ramps controllers/messengers, selectors, and related glob patterns). > > Adds an additional `**/money-movement/**` ownership rule and updates the Swaps co-owned `parseAmount` entries to use the new Money Movement team. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8efd4d2a77beac6b1ae06f739b285b02bd6959a3. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .github/CODEOWNERS | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b411351a16fb..3f3edeb14c66 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -59,16 +59,17 @@ scripts/set-secrets-from-config.js @MetaMask/mobile-adm # Platform & Snaps Code Fencing File metro.transform.js @MetaMask/mobile-platform @MetaMask/core-platform -# Ramps Team -app/components/UI/Ramp/ @MetaMask/ramp -app/reducers/fiatOrders/ @MetaMask/ramp -app/core/Engine/controllers/ramps-controller @MetaMask/ramp -app/core/Engine/messengers/ramps-controller-messenger @MetaMask/ramp -app/core/Engine/messengers/ramps-service-messenger @MetaMask/ramp -app/selectors/rampsController @MetaMask/ramp -**/Ramp/** @MetaMask/ramp -**/ramp/** @MetaMask/ramp -**/ramps/** @MetaMask/ramp +# Money Movement Team (formerly Ramps) +app/components/UI/Ramp/ @MetaMask/money-movement +app/reducers/fiatOrders/ @MetaMask/money-movement +app/core/Engine/controllers/ramps-controller @MetaMask/money-movement +app/core/Engine/messengers/ramps-controller-messenger @MetaMask/money-movement +app/core/Engine/messengers/ramps-service-messenger @MetaMask/money-movement +app/selectors/rampsController @MetaMask/money-movement +**/Ramp/** @MetaMask/money-movement +**/ramp/** @MetaMask/money-movement +**/ramps/** @MetaMask/money-movement +**/money-movement/** @MetaMask/money-movement # Card Team app/components/UI/Card/ @MetaMask/card @@ -287,9 +288,9 @@ tests/flows/ @MetaMask/qa # Note: Test builds (main-test, flask-test) in build/builds.yml are owned by QA team # but the file itself is protected by mobile-platform for consistency -# Co-owned by Swaps and Ramps teams -app/util/parseAmount.ts @MetaMask/swaps-engineers @MetaMask/ramp -app/util/parseAmount.test.ts @MetaMask/swaps-engineers @MetaMask/ramp +# Co-owned by Swaps and Money Movement teams +app/util/parseAmount.ts @MetaMask/swaps-engineers @MetaMask/money-movement +app/util/parseAmount.test.ts @MetaMask/swaps-engineers @MetaMask/money-movement # Snapshots – no code owners assigned # This allows anyone with write access to approve changes to any *.snap files. From 7362d38e848d266dadc73e94231e1828604288f1 Mon Sep 17 00:00:00 2001 From: Prithpal Sooriya Date: Tue, 31 Mar 2026 09:47:01 +0200 Subject: [PATCH 3/8] fix(token-details): cp-7.72.0 make sticky swap defaults balance-aware (#28057) ## **Description** Implements balance-aware Swap defaults **only for the sticky Swap button in Token Details**. When users tap the sticky Swap CTA from Token Details: - If the viewed token has a positive balance, Swap opens with: - `From = current token` - `To = swap default` - If the viewed token has zero balance, Swap opens with: - `From = best available token (same existing selection logic used by sticky Buy flow)` - `To = current token` Scope is intentionally narrow to avoid regressions in other swap entry points: - Added a dedicated `handleStickySwapPress` in `useTokenActions` - Wired `TokenDetailsStickyFooter` to call `onSwap` (sticky-only handler) - Kept existing non-sticky/legacy swap navigation behavior unchanged - Updated Security & Trust screen to continue using generic swap behavior ## **Changelog** CHANGELOG entry: Fixed Token Details sticky Swap button defaults to use a balance-aware source token selection. ## **Related issues** Fixes: N/A Refs: https://consensyssoftware.atlassian.net/browse/ASSETS-2972 https://github.com/MetaMask/metamask-mobile/issues/28050 ## **Manual testing steps** ```gherkin Feature: Token Details sticky swap defaults Scenario: Token has positive balance Given user has balance in token X And user opens Token Details for token X When user taps the sticky Swap button Then Swap opens with token X as the source token And destination token is not prefilled as token X Scenario: Token has zero balance but user has other eligible assets Given user has zero balance in token X And user has positive balance in another eligible token Y And user opens Token Details for token X When user taps the sticky Swap button Then Swap opens with token Y as the source token And token X is prefilled as the destination token Scenario: Legacy/non-sticky swap path remains unchanged Given user opens Token Details When user navigates via non-sticky swap entry path Then existing swap defaults behave as before ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** Tested in Offsite - Approval from @bergarces and @AmarildoGr ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots.
Open in Web Open in Cursor 
--------- Co-authored-by: Prithpal Sooriya --- .../Views/SecurityTrustScreen.tsx | 4 +- .../TokenDetails/Views/TokenDetails.test.tsx | 10 +- .../UI/TokenDetails/Views/TokenDetails.tsx | 19 +- .../components/TokenDetailsStickyFooter.tsx | 6 +- .../hooks/useTokenActions.test.ts | 310 ++++++++++++++++++ .../UI/TokenDetails/hooks/useTokenActions.ts | 75 ++++- 6 files changed, 402 insertions(+), 22 deletions(-) diff --git a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx index 5fe9d0a2f158..d94fa2172af1 100644 --- a/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx +++ b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx @@ -63,7 +63,7 @@ const SecurityTrustScreen: React.FC = () => { const networkName = useNetworkName(params?.chainId as Hex); // Get action handlers from hook (single source of truth) - const { onBuy, goToSwaps, hasEligibleSwapTokens, networkModal } = + const { onBuy, handleStickySwapPress, hasEligibleSwapTokens, networkModal } = useTokenActions({ token: params, networkName, @@ -619,7 +619,7 @@ const SecurityTrustScreen: React.FC = () => { token={params} securityData={securityData} onBuy={onBuy} - goToSwaps={goToSwaps} + onSwap={handleStickySwapPress} hasEligibleSwapTokens={hasEligibleSwapTokens} /> diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx index 4206c372f7d1..c9cf0d7ca132 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.test.tsx @@ -80,6 +80,7 @@ jest.mock('../../Ramp/hooks/useTokenBuyability', () => ({ })); const mockGoToSwaps = jest.fn(); +const mockHandleStickySwapPress = jest.fn(); const mockOnBuy = jest.fn(); const mockUseTokenActions = jest.fn(); jest.mock('../hooks/useTokenActions', () => ({ @@ -217,6 +218,7 @@ describe('TokenDetails', () => { goToSwaps: mockGoToSwaps, handleBuyPress: jest.fn(), handleSellPress: jest.fn(), + handleStickySwapPress: mockHandleStickySwapPress, hasEligibleSwapTokens: true, networkModal: null, }); @@ -291,12 +293,7 @@ describe('TokenDetails', () => { fireEvent.press(getByText('Swap')); - expect(mockGoToSwaps).toHaveBeenCalledWith( - undefined, - undefined, - undefined, - true, - ); + expect(mockHandleStickySwapPress).toHaveBeenCalledTimes(1); }); it('shows only Swap when user has eligible tokens but token is not buyable', () => { @@ -319,6 +316,7 @@ describe('TokenDetails', () => { goToSwaps: mockGoToSwaps, handleBuyPress: jest.fn(), handleSellPress: jest.fn(), + handleStickySwapPress: mockHandleStickySwapPress, hasEligibleSwapTokens: false, networkModal: null, }); diff --git a/app/components/UI/TokenDetails/Views/TokenDetails.tsx b/app/components/UI/TokenDetails/Views/TokenDetails.tsx index 70a32d6d2f14..637177e0893a 100644 --- a/app/components/UI/TokenDetails/Views/TokenDetails.tsx +++ b/app/components/UI/TokenDetails/Views/TokenDetails.tsx @@ -153,11 +153,18 @@ const TokenDetails: React.FC<{ ///: END:ONLY_INCLUDE_IF } = useTokenBalance(token); - const { onBuy, onSend, onReceive, goToSwaps, hasEligibleSwapTokens } = - useTokenActions({ - token, - networkName, - }); + const { + onBuy, + onSend, + onReceive, + goToSwaps, + handleStickySwapPress, + hasEligibleSwapTokens, + } = useTokenActions({ + token, + networkName, + currentTokenBalance: balance, + }); // Swaps view should always scroll to top when navigating from the token details view const goToSwapsFromDetails = useCallback( @@ -306,7 +313,7 @@ const TokenDetails: React.FC<{ token={token} securityData={securityData} onBuy={onBuy} - goToSwaps={goToSwapsFromDetails} + onSwap={handleStickySwapPress} hasEligibleSwapTokens={hasEligibleSwapTokens} /> )} diff --git a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx index 5efd3e2a9705..e67451cdc773 100644 --- a/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx +++ b/app/components/UI/TokenDetails/components/TokenDetailsStickyFooter.tsx @@ -23,7 +23,7 @@ interface TokenStickyFooterProps { securityData: TokenSecurityData | null | undefined; /** Action handlers from parent's useTokenActions hook */ onBuy: () => void; - goToSwaps: () => void; + onSwap: () => void; hasEligibleSwapTokens: boolean; } @@ -31,7 +31,7 @@ const TokenDetailsStickyFooter: React.FC = ({ token, securityData, onBuy, - goToSwaps, + onSwap, hasEligibleSwapTokens, }) => { const navigation = useNavigation(); @@ -139,7 +139,7 @@ const TokenDetailsStickyFooter: React.FC = ({ size: ButtonSize.Lg, onPress: () => handleFooterAction( - () => goToSwaps(), + onSwap, strings('asset_overview.swap'), ), }, diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index 2c0182cfb033..d4f2dfddeac2 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts @@ -276,6 +276,7 @@ describe('useTokenActions', () => { expect(result.current).toHaveProperty('goToSwaps'); expect(result.current).toHaveProperty('handleBuyPress'); expect(result.current).toHaveProperty('handleSellPress'); + expect(result.current).toHaveProperty('handleStickySwapPress'); expect(result.current).toHaveProperty('networkModal'); expect(typeof result.current.onBuy).toBe('function'); @@ -284,6 +285,7 @@ describe('useTokenActions', () => { expect(typeof result.current.goToSwaps).toBe('function'); expect(typeof result.current.handleBuyPress).toBe('function'); expect(typeof result.current.handleSellPress).toBe('function'); + expect(typeof result.current.handleStickySwapPress).toBe('function'); }); }); @@ -727,6 +729,7 @@ describe('useTokenActions', () => { symbol: 'ETH', name: 'Ethereum', image: '', + isNative: true, fiat: { balance: 2000 }, }, ], @@ -785,4 +788,311 @@ describe('useTokenActions', () => { ); }); }); + + /** + * Swap entry from Token Details sticky CTA (`handleStickySwapPress`): + * - Has Balance: + * -- from: current token + * -- to: undefined (swap UI picks default dest -- e.g. mUSD / last used) + * + * - No Balance: + * -- from: `buySourceToken` (best available) + * -- to: current token + * + * `buySourceToken` priority: + * 1. Same chain token (not current) with highest fiat balance + * 2. Native token (ETH, POL, etc.) on any chain with highest fiat balance + * 3. Last swapped token (Not supported — needs data source) + * 4. Most used token (Not supported — needs data source) + * 5. Fallback: any token on any chain with highest fiat balance + */ + describe('handleStickySwapPress', () => { + const WETH_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'; + const USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + const POLYGON_USDC_ADDRESS = '0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359'; + + interface StickySwapUserAsset { + assetId: string; + chainId: string; + decimals: number; + symbol: string; + name: string; + image: string; + isNative?: boolean; + fiat?: { balance: number }; + } + + const arrangeToken = (balance: string): TokenI => + ({ ...defaultToken, balance }) as TokenI; + + /** Mirrors `selectAssetsBySelectedAccountGroup` shape (values flattened in hook). */ + const arrangeUserAssets = ( + assetsByChain: Record = {}, + ) => assetsByChain; + + const userAsset = (params: { + assetId: string; + chainId?: string; + symbol: string; + name?: string; + decimals?: number; + fiatBalance?: number; + isNative?: boolean; + }): StickySwapUserAsset => ({ + assetId: params.assetId, + chainId: params.chainId ?? '0x1', + decimals: params.decimals ?? 18, + symbol: params.symbol, + name: params.name ?? params.symbol, + image: '', + isNative: params.isNative ?? false, + ...(params.fiatBalance !== undefined + ? { fiat: { balance: params.fiatBalance } } + : {}), + }); + + const hasBalanceCases = [ + { + name: 'from current token, to default (undefined dest for swap UI)', + token: arrangeToken('1'), + userAssets: arrangeUserAssets(), + expectedDestinationAddress: undefined, + }, + { + name: 'currentTokenBalance overrides token.balance when positive', + token: arrangeToken('0'), + currentTokenBalance: '0.5', + userAssets: arrangeUserAssets({ + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 9000, + }), + ], + }), + expectedDestinationAddress: undefined, + }, + ]; + + it.each(hasBalanceCases)( + 'has balance — $name', + ({ + token, + currentTokenBalance, + userAssets, + expectedDestinationAddress, + }) => { + selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + userAssets, + ); + + const { result } = renderHook(() => + useTokenActions({ + token, + networkName: 'Ethereum Mainnet', + ...(currentTokenBalance !== undefined && { currentTokenBalance }), + }), + ); + + result.current.handleStickySwapPress(); + + expect(mockGoToSwaps).toHaveBeenCalledTimes(1); + expect(mockGoToSwaps).toHaveBeenCalledWith( + expect.objectContaining({ address: defaultToken.address }), + expectedDestinationAddress !== undefined + ? expect.objectContaining({ address: expectedDestinationAddress }) + : undefined, + undefined, + true, + ); + }, + ); + + const noBalanceCases = [ + { + name: 'Priority 1: same chain: best token by fiat to current token', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 1000, + }), + userAsset({ + assetId: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + fiatBalance: 5000, + }), + ], + }), + expectedSourceAddress: USDC_ADDRESS, // USDC has higher fiat balance than WETH + expectedDestinationAddress: defaultToken.address, + }, + { + name: 'Priority 1: same chain: excludes current asset on same chain; next-best same-chain wins', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x1': [ + userAsset({ + assetId: defaultToken.address, + symbol: defaultToken.symbol, + fiatBalance: 9999, + }), + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 100, + }), + ], + }), + expectedSourceAddress: WETH_ADDRESS, + expectedDestinationAddress: defaultToken.address, + }, + { + name: 'Priority 2: cross chain: native token with highest fiat', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x89': [ + userAsset({ + assetId: POLYGON_USDC_ADDRESS, + chainId: '0x89', + symbol: 'USDC', + decimals: 6, + fiatBalance: 5000, + }), + userAsset({ + assetId: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + symbol: 'POL', + name: 'POL', + decimals: 18, + fiatBalance: 200, + isNative: true, + }), + ], + }), + expectedSourceAddress: '0x0000000000000000000000000000000000001010', // cross chain swap, we prefer the native token + expectedDestinationAddress: defaultToken.address, + }, + { + name: 'Priority 2: cross chain: picks native token with highest fiat among multiple native tokens', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x89': [ + userAsset({ + assetId: '0x0000000000000000000000000000000000001010', + chainId: '0x89', + symbol: 'POL', + name: 'POL', + decimals: 18, + fiatBalance: 200, + isNative: true, + }), + ], + '0xa': [ + userAsset({ + assetId: '0x0000000000000000000000000000000000000000', + chainId: '0xa', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + fiatBalance: 3000, + isNative: true, + }), + ], + }), + expectedSourceAddress: '0x0000000000000000000000000000000000000000', // 0xa native token has the highest native balance + expectedDestinationAddress: defaultToken.address, + }, + { + name: 'Priority 2: no native tokens available: falls back to highest fiat non-native cross-chain token', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x89': [ + userAsset({ + assetId: POLYGON_USDC_ADDRESS, + chainId: '0x89', + symbol: 'USDC', + decimals: 6, + fiatBalance: 800, + }), + ], + }), + expectedSourceAddress: POLYGON_USDC_ADDRESS, + expectedDestinationAddress: defaultToken.address, + }, + + { + name: 'Edge case: no eligible source: only current token with fiat — falls back to current, undefined dest', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x1': [ + userAsset({ + assetId: defaultToken.address, + symbol: defaultToken.symbol, + fiatBalance: 100, + }), + ], + }), + expectedSourceAddress: defaultToken.address, + expectedDestinationAddress: undefined, + }, + { + name: 'Edge case: no eligible source: other tokens have zero or missing fiat — falls back to current, undefined dest', + token: arrangeToken('0'), + userAssets: arrangeUserAssets({ + '0x1': [ + userAsset({ + assetId: WETH_ADDRESS, + symbol: 'WETH', + fiatBalance: 0, + }), + userAsset({ + assetId: USDC_ADDRESS, + symbol: 'USDC', + decimals: 6, + }), + ], + }), + expectedSourceAddress: defaultToken.address, + expectedDestinationAddress: undefined, + }, + ]; + + it.each(noBalanceCases)( + 'no balance — $name', + ({ + token, + userAssets, + expectedSourceAddress, + expectedDestinationAddress, + }) => { + selectorMocks.mockSelectAssetsBySelectedAccountGroup.mockReturnValue( + userAssets, + ); + + const { result } = renderHook(() => + useTokenActions({ + token, + networkName: 'Ethereum Mainnet', + }), + ); + + result.current.handleStickySwapPress(); + + expect(mockGoToSwaps).toHaveBeenCalledTimes(1); + expect(mockGoToSwaps).toHaveBeenCalledWith( + expect.objectContaining({ address: expectedSourceAddress }), + expectedDestinationAddress !== undefined + ? expect.objectContaining({ address: expectedDestinationAddress }) + : undefined, + undefined, + true, + ); + }, + ); + }); }); diff --git a/app/components/UI/TokenDetails/hooks/useTokenActions.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.ts index 08af6153941c..756d3c3dada9 100644 --- a/app/components/UI/TokenDetails/hooks/useTokenActions.ts +++ b/app/components/UI/TokenDetails/hooks/useTokenActions.ts @@ -104,6 +104,10 @@ export interface UseTokenActionsResult { handleBuyPress: () => void; /** Sticky bar Sell handler - current asset as source, mUSD/native as destination */ handleSellPress: () => void; + /** Sticky token-details Swap handler with balance-aware defaults */ + handleStickySwapPress: () => void; + /** Sticky token-details Swap visibility flag */ + hasEligibleStickySwapTokens: boolean; /** Whether the user has any tokens with positive balance that can be used as a swap source */ hasEligibleSwapTokens: boolean; networkModal: React.ReactNode; @@ -112,6 +116,8 @@ export interface UseTokenActionsResult { export interface UseTokenActionsParams { token: TokenI; networkName?: string; + /** Optional up-to-date token balance from Token Details balance hook */ + currentTokenBalance?: string; } /** @@ -121,6 +127,7 @@ export interface UseTokenActionsParams { export const useTokenActions = ({ token, networkName, + currentTokenBalance, }: UseTokenActionsParams): UseTokenActionsResult => { const navigation = useNavigation(); @@ -395,10 +402,9 @@ export const useTokenActions = ({ }; } - // Priority 2: Find highest USD value token on any chain (with positive balance) - // Only exclude if BOTH address AND chainId match (same exact token) + // Eligible cross-chain assets: exclude exact same token (address + chain match) // This allows cross-chain bridging of native tokens that share the zero address - const allAssets = userAssets + const crossChainAssets = userAssets .filter( (a) => !( @@ -408,8 +414,25 @@ export const useTokenActions = ({ ) .sort((a, b) => (b.fiat?.balance ?? 0) - (a.fiat?.balance ?? 0)); - if (allAssets.length > 0) { - const asset = allAssets[0]; + // Priority 2: Prefer native tokens (ETH, POL, etc.) with highest fiat balance + const nativeAsset = crossChainAssets.find((a) => a.isNative); + if (nativeAsset) { + return { + address: nativeAsset.assetId, + chainId: nativeAsset.chainId as Hex | CaipChainId, + decimals: nativeAsset.decimals, + symbol: nativeAsset.symbol, + name: nativeAsset.name, + image: nativeAsset.image, + }; + } + + // Priority 3 – Last swapped token (needs selector/data source) + // Priority 4 – Most used token (needs selector/data source) + + // Fallback: highest USD value token on any chain + if (crossChainAssets.length > 0) { + const asset = crossChainAssets[0]; return { address: asset.assetId, chainId: asset.chainId as Hex | CaipChainId, @@ -423,6 +446,21 @@ export const useTokenActions = ({ return null; }, [userAssetsMap, token.chainId, token.address]); + const currentTokenHasBalance = useMemo(() => { + const balanceToCheck = currentTokenBalance ?? token.balance; + + if (typeof balanceToCheck === 'number') { + return balanceToCheck > 0; + } + + if (typeof balanceToCheck === 'string') { + const parsedBalance = Number(balanceToCheck.replace(/,/gu, '').trim()); + return Number.isFinite(parsedBalance) && parsedBalance > 0; + } + + return false; + }, [currentTokenBalance, token.balance]); + const handleBuyPress = useCallback(() => { // If user has no eligible tokens to swap with, route to on-ramp if (!buySourceToken) { @@ -470,6 +508,30 @@ export const useTokenActions = ({ ); }, [goToSwaps, currentTokenAsBridgeToken]); + // Sticky Token Details swap button only: + // - If current token has balance, keep current token as source + // - If current token has no balance, prefill source with best available token and current as destination + const handleStickySwapPress = useCallback(() => { + if (!goToSwaps) return; + + if (currentTokenHasBalance) { + goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); + return; + } + + if (buySourceToken) { + goToSwaps(buySourceToken, currentTokenAsBridgeToken, undefined, true); + return; + } + + goToSwaps(currentTokenAsBridgeToken, undefined, undefined, true); + }, [ + goToSwaps, + currentTokenHasBalance, + currentTokenAsBridgeToken, + buySourceToken, + ]); + return { onBuy, onSend, @@ -477,6 +539,9 @@ export const useTokenActions = ({ goToSwaps, handleBuyPress, handleSellPress, + handleStickySwapPress, + hasEligibleStickySwapTokens: + buySourceToken !== null || currentTokenHasBalance, hasEligibleSwapTokens: buySourceToken !== null, networkModal, }; From 952a1bfcb58a78711fce8dba05d006734b896d29 Mon Sep 17 00:00:00 2001 From: Baptiste Marchand <75846779+baptiste-marchand@users.noreply.github.com> Date: Tue, 31 Mar 2026 10:39:00 +0200 Subject: [PATCH 4/8] feat: Braze SDK integration [GE-107] cp-7.72.0 (#27881) ## **Description** This PR is the first part of Braze integration. It's currently only creating Braze users based on their profile id, and registering their FCM/APN tokens to start filling up Braze database with tokens. ## **Changelog** CHANGELOG entry: Added Braze SDK to enhance push notifications capabilities ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/GE-107 ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adds a new third-party push/engagement SDK and changes native push handling/initialization on both iOS and Android, which could affect notification delivery and app startup. Build tooling changes (Kotlin forcing + patched dependency) also increase risk of platform-specific build regressions. > > **Overview** > Introduces Braze as a new mobile dependency and wires it into both native platforms to start registering push tokens and supporting Braze-driven notifications. > > On **Android**, adds Braze API key/endpoint injection via Gradle resources, registers `BrazeFirebaseMessagingService` as the primary FCM handler with a fallback to the existing RN Firebase service, and initializes Braze lifecycle callbacks; it also pins Kotlin/serialization versions and patches `@braze/react-native-sdk` to keep builds compatible with Kotlin 1.9. > > On **iOS**, adds Braze keys to Info.plists and initializes a shared `Braze` instance in `AppDelegate` using build-injected credentials, enabling Braze push automation while preserving the existing permission flow. > > On the JS side, adds a small `app/core/Braze` layer plus `useBrazeIdentity` hooked into `useIdentityEffects` to call `Braze.changeUser(profileId)` on sign-in (skipped in E2E), along with Jest mocks/tests and new build/CI env vars for Braze secrets (examples, `build.sh`, workflow, and config verification). > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 29b58cdaa6fdf6c06047d23787721d27906b718c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .android.env.example | 2 + .github/workflows/push-eas-update.yml | 3 + .ios.env.example | 2 + .js.env.example | 5 ++ ...e-sdk-npm-19.1.0-076-reactmoduleinfo.patch | 22 +++++++ android/app/build.gradle | 4 ++ android/app/src/main/AndroidManifest.xml | 8 +++ .../main/java/io/metamask/MainApplication.kt | 2 + android/app/src/main/res/values/braze.xml | 19 ++++++ android/build.gradle | 29 +++++++++ app/core/Braze/index.test.ts | 63 +++++++++++++++++++ app/core/Braze/index.ts | 32 ++++++++++ app/core/Braze/useBrazeIdentity.test.ts | 45 +++++++++++++ app/core/Braze/useBrazeIdentity.ts | 21 +++++++ .../useIdentityEffects.test.ts | 3 + .../useIdentityEffects/useIdentityEffects.ts | 4 ++ app/util/test/testSetup.js | 10 +++ builds.yml | 4 ++ ios/MetaMask/AppDelegate.h | 3 + ios/MetaMask/AppDelegate.m | 31 ++++++++- ios/MetaMask/Info.plist | 4 ++ ios/MetaMask/MetaMask-Flask-Info.plist | 4 ++ ios/MetaMask/MetaMask-QA-Info.plist | 4 ++ ios/Podfile.lock | 39 ++++++++++++ package.json | 1 + scripts/build.sh | 3 + scripts/verify-build-config.js | 3 + yarn.lock | 15 +++++ 28 files changed, 382 insertions(+), 3 deletions(-) create mode 100644 .yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch create mode 100644 android/app/src/main/res/values/braze.xml create mode 100644 app/core/Braze/index.test.ts create mode 100644 app/core/Braze/index.ts create mode 100644 app/core/Braze/useBrazeIdentity.test.ts create mode 100644 app/core/Braze/useBrazeIdentity.ts diff --git a/.android.env.example b/.android.env.example index d630c9b4f087..c9805d0a1808 100644 --- a/.android.env.example +++ b/.android.env.example @@ -2,3 +2,5 @@ export MM_FOX_CODE="EXAMPLE_FOX_CODE" export MM_BRANCH_KEY_TEST= export MM_BRANCH_KEY_LIVE= export METAMASK_BUILD_TYPE= +export MM_BRAZE_API_KEY_ANDROID= +export MM_BRAZE_SDK_ENDPOINT="sdk.iad-07.braze.com" diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 426db1b16153..5eac948db248 100644 --- a/.github/workflows/push-eas-update.yml +++ b/.github/workflows/push-eas-update.yml @@ -332,6 +332,9 @@ jobs: QUICKNODE_BSC_URL: ${{ secrets.QUICKNODE_BSC_URL }} QUICKNODE_SEI_URL: ${{ secrets.QUICKNODE_SEI_URL }} MM_CHARTING_LIBRARY_URL: ${{ secrets.MM_CHARTING_LIBRARY_URL }} + MM_BRAZE_API_KEY_IOS: ${{ secrets.MM_BRAZE_API_KEY_IOS }} + MM_BRAZE_API_KEY_ANDROID: ${{ secrets.MM_BRAZE_API_KEY_ANDROID }} + MM_BRAZE_SDK_ENDPOINT: ${{ secrets.MM_BRAZE_SDK_ENDPOINT }} steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/.ios.env.example b/.ios.env.example index bd49b067660f..dd2d4ddd083f 100644 --- a/.ios.env.example +++ b/.ios.env.example @@ -1,3 +1,5 @@ MM_FOX_CODE = EXAMPLE_FOX_CODE MM_BRANCH_KEY_TEST = MM_BRANCH_KEY_LIVE = +MM_BRAZE_API_KEY_IOS = +MM_BRAZE_SDK_ENDPOINT = sdk.iad-07.braze.com diff --git a/.js.env.example b/.js.env.example index 83b4d97b81ba..13cec147ce2f 100644 --- a/.js.env.example +++ b/.js.env.example @@ -224,3 +224,8 @@ export MM_EXTENSION_UX_PNA25="" ## Metro export METRO_RESET_CACHE="true" + +## Braze +export MM_BRAZE_SDK_ENDPOINT=sdk.iad-07.braze.com +export MM_BRAZE_API_KEY_IOS= +export MM_BRAZE_API_KEY_ANDROID= diff --git a/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch b/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch new file mode 100644 index 000000000000..d7fa9200cd2a --- /dev/null +++ b/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch @@ -0,0 +1,22 @@ +# Braze SDK 19.1.0 ships with Kotlin 2.1-era code, but this MetaMask compiles +# with Kotlin 1.9.x. We pin Kotlin stdlib/serialization in android/build.gradle +# to fix the metadata incompatibility, but that downgrade exposes a second issue: +# the 7-arg ReactModuleInfo constructor (with `hasConstants`) used in Kotlin 1.9 +# differs from the 6-arg variant the Braze source calls. Without this patch the +# build fails with: +# "None of the following functions can be called with the arguments supplied: +# public constructor ReactModuleInfo(...hasConstants...) / (...without...)" +# This patch adds the missing `hasConstants = true` argument. +diff --git a/android/src/main/java/com/braze/reactbridge/BrazeReactBridgePackage.kt b/android/src/main/java/com/braze/reactbridge/BrazeReactBridgePackage.kt +--- a/android/src/main/java/com/braze/reactbridge/BrazeReactBridgePackage.kt ++++ b/android/src/main/java/com/braze/reactbridge/BrazeReactBridgePackage.kt +@@ -25,8 +25,9 @@ + moduleInfos[BrazeReactBridgeImpl.NAME] = ReactModuleInfo( + name = BrazeReactBridgeImpl.NAME, + className = BrazeReactBridgeImpl.NAME, + canOverrideExistingModule = false, + needsEagerInit = false, ++ hasConstants = true, + isCxxModule = false, + isTurboModule = true + ) diff --git a/android/app/build.gradle b/android/app/build.gradle index aa23b78eea78..fb012bb9844b 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -193,6 +193,10 @@ android { testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders.MM_BRANCH_KEY_TEST = "$System.env.MM_BRANCH_KEY_TEST" manifestPlaceholders.MM_BRANCH_KEY_LIVE = "$System.env.MM_BRANCH_KEY_LIVE" + + // Braze SDK credentials — names match .android.env / build.sh exports + resValue "string", "com_braze_api_key", "${System.env.MM_BRAZE_API_KEY_ANDROID ?: ''}" + resValue "string", "com_braze_custom_endpoint", "${System.env.MM_BRAZE_SDK_ENDPOINT ?: ''}" } packagingOptions { diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 845d99f64711..c8727563234c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -192,6 +192,14 @@ + + + + + + + + + + + + true + + @string/gcm_defaultSenderId + + + true + io.invertase.firebase.messaging.ReactNativeFirebaseMessagingService + diff --git a/android/build.gradle b/android/build.gradle index 01a8c08d81d4..c700511130b0 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -39,3 +39,32 @@ buildscript { } } } + +// Braze Android SDK 41.x transitively pulls Kotlin 2.1-era stdlib and kotlinx-serialization +// artifacts whose metadata the project's Kotlin 1.9.x compiler cannot read. +// We force compatible versions on: +// - Braze modules : so the Braze bridge itself compiles against 1.9 metadata. +// - :app : because Braze declares its SDK dependency with `api` scope, Gradle's +// "highest version wins" strategy promotes kotlin-stdlib 2.1 into :app's +// compile classpath, breaking every .kt file in the app module. +// Other subprojects (react-native, expo, firebase, etc.) are left untouched. +def brazeKotlinStdlibVersion = "1.9.25" +def brazeKotlinxSerializationVersion = "1.6.3" +subprojects { subproject -> + subproject.afterEvaluate { + def name = subproject.name.toLowerCase() + if (name != 'app' && !name.contains('braze')) return + + subproject.configurations.configureEach { configuration -> + configuration.resolutionStrategy { + force "org.jetbrains.kotlin:kotlin-stdlib:${brazeKotlinStdlibVersion}" + force "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${brazeKotlinStdlibVersion}" + force "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${brazeKotlinStdlibVersion}" + force "org.jetbrains.kotlinx:kotlinx-serialization-core:${brazeKotlinxSerializationVersion}" + force "org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:${brazeKotlinxSerializationVersion}" + force "org.jetbrains.kotlinx:kotlinx-serialization-json:${brazeKotlinxSerializationVersion}" + force "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:${brazeKotlinxSerializationVersion}" + } + } + } +} diff --git a/app/core/Braze/index.test.ts b/app/core/Braze/index.test.ts new file mode 100644 index 000000000000..4a9cb904f6de --- /dev/null +++ b/app/core/Braze/index.test.ts @@ -0,0 +1,63 @@ +import Braze from '@braze/react-native-sdk'; +import { setBrazeUser } from './index'; + +const mockGetSessionProfile = jest.fn(); + +jest.mock('../Engine/Engine', () => ({ + __esModule: true, + default: { + context: { + AuthenticationController: { + getSessionProfile: () => mockGetSessionProfile(), + }, + }, + }, +})); + +jest.mock('@braze/react-native-sdk', () => ({ + __esModule: true, + default: { + changeUser: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + Events: { PUSH_NOTIFICATION_EVENT: 'push_notification_event' }, + }, +})); + +describe('Braze service', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('setBrazeUser', () => { + it('calls changeUser with profileId when session has valid profile', async () => { + mockGetSessionProfile.mockResolvedValue({ + profileId: 'test-profile-id-123', + identifierId: 'id', + metaMetricsId: 'mm-id', + }); + + await setBrazeUser(); + + expect(Braze.changeUser).toHaveBeenCalledWith('test-profile-id-123'); + }); + + it('does nothing when session profile has no profileId', async () => { + mockGetSessionProfile.mockResolvedValue({ + profileId: '', + identifierId: 'id', + metaMetricsId: 'mm-id', + }); + + await setBrazeUser(); + + expect(Braze.changeUser).not.toHaveBeenCalled(); + }); + + it('handles errors gracefully', async () => { + mockGetSessionProfile.mockRejectedValue(new Error('Session error')); + + await expect(setBrazeUser()).resolves.toBeUndefined(); + expect(Braze.changeUser).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/core/Braze/index.ts b/app/core/Braze/index.ts new file mode 100644 index 000000000000..b3c9364d35d2 --- /dev/null +++ b/app/core/Braze/index.ts @@ -0,0 +1,32 @@ +import Braze from '@braze/react-native-sdk'; +import Logger from '../../util/Logger'; +import { isE2E } from '../../util/test/utils'; +import Engine from '../Engine/Engine'; + +/** + * Set the Braze external user ID to the MetaMask profile ID. + * This creates/switches the Braze user so push tokens, events, + * and attributes are associated with this identity. + * + * Callers are responsible for gating on sign-in state before invoking this. + * + * Skipped during E2E (IS_TEST / METAMASK_ENVIRONMENT=e2e) so CI does not create + * Braze profiles from mocked identity sessions. + */ +export async function setBrazeUser(): Promise { + if (isE2E) { + return; + } + + try { + const { AuthenticationController } = Engine.context; + + const sessionProfile = await AuthenticationController.getSessionProfile(); + if (sessionProfile?.profileId) { + Braze.changeUser(sessionProfile.profileId); + Logger.log('[Braze] Identified user with profileId'); + } + } catch (error) { + Logger.error(error as Error, '[Braze] Failed to set Braze user'); + } +} diff --git a/app/core/Braze/useBrazeIdentity.test.ts b/app/core/Braze/useBrazeIdentity.test.ts new file mode 100644 index 000000000000..726de00d6649 --- /dev/null +++ b/app/core/Braze/useBrazeIdentity.test.ts @@ -0,0 +1,45 @@ +import { renderHookWithProvider } from '../../util/test/renderWithProvider'; +import { useBrazeIdentity } from './useBrazeIdentity'; +import { setBrazeUser } from './index'; +import backgroundState from '../../util/test/initial-background-state.json'; + +jest.mock('./index', () => ({ + ...jest.requireActual('./index'), + setBrazeUser: jest.fn(), +})); + +const mockSetBrazeUser = jest.mocked(setBrazeUser); + +const createState = (isSignedIn: boolean) => + ({ + engine: { + backgroundState: { + ...backgroundState, + AuthenticationController: { + isSignedIn, + }, + }, + }, + }) as unknown as Record; + +describe('useBrazeIdentity', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls setBrazeUser on mount when already signed in', () => { + renderHookWithProvider(() => useBrazeIdentity(), { + state: createState(true), + }); + + expect(mockSetBrazeUser).toHaveBeenCalledTimes(1); + }); + + it('does not call setBrazeUser on mount when not signed in', () => { + renderHookWithProvider(() => useBrazeIdentity(), { + state: createState(false), + }); + + expect(mockSetBrazeUser).not.toHaveBeenCalled(); + }); +}); diff --git a/app/core/Braze/useBrazeIdentity.ts b/app/core/Braze/useBrazeIdentity.ts new file mode 100644 index 000000000000..c90ec8496226 --- /dev/null +++ b/app/core/Braze/useBrazeIdentity.ts @@ -0,0 +1,21 @@ +import { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { selectIsSignedIn } from '../../selectors/identity'; +import { setBrazeUser } from './index'; + +/** + * Reacts to MetaMask profile sign-in and sets the Braze external user ID + * to the profile ID via `Braze.changeUser()`. + * + * This is the sole mechanism for creating/identifying Braze users — + * Braze is intentionally decoupled from the Segment analytics pipeline. + */ +export function useBrazeIdentity(): void { + const isSignedIn = useSelector(selectIsSignedIn); + + useEffect(() => { + if (isSignedIn) { + setBrazeUser(); + } + }, [isSignedIn]); +} diff --git a/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.test.ts b/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.test.ts index 1e945696a477..ed1a16210857 100644 --- a/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.test.ts +++ b/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.test.ts @@ -7,6 +7,9 @@ import { useIdentityEffects } from './useIdentityEffects'; jest.mock('../useAuthentication'); jest.mock('../useAccountSyncing'); jest.mock('../useContactSyncing'); +jest.mock('../../../../core/Braze/useBrazeIdentity', () => ({ + useBrazeIdentity: jest.fn(), +})); describe('useIdentityEffects', () => { const mockUseAutoSignIn = jest.mocked(useAutoSignIn); diff --git a/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.ts b/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.ts index cae37d9f6860..fb039a113836 100644 --- a/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.ts +++ b/app/util/identity/hooks/useIdentityEffects/useIdentityEffects.ts @@ -2,10 +2,12 @@ import { useEffect } from 'react'; import { useAccountSyncing } from '../useAccountSyncing'; import { useContactSyncing } from '../useContactSyncing'; import { useAutoSignIn, useAutoSignOut } from '../useAuthentication'; +import { useBrazeIdentity } from '../../../../core/Braze/useBrazeIdentity'; /** * Takes care of various identity effects. * - Automatically signs users in or out based on the app state. + * - Syncs profile ID to Braze on sign-in/sign-out. */ export const useIdentityEffects = () => { const { dispatchAccountSyncing, shouldDispatchAccountSyncing } = @@ -15,6 +17,8 @@ export const useIdentityEffects = () => { const { autoSignIn, shouldAutoSignIn } = useAutoSignIn(); const { autoSignOut, shouldAutoSignOut } = useAutoSignOut(); + useBrazeIdentity(); + /** * Back up & sync effects */ diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index e7086892d9f0..6cfa8b19cfcb 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -545,6 +545,16 @@ jest.mock('@notifee/react-native', () => require('@notifee/react-native/jest-mock'), ); +// ESM-only package; Jest must not load node_modules source (transformIgnorePatterns) +jest.mock('@braze/react-native-sdk', () => ({ + __esModule: true, + default: { + changeUser: jest.fn(), + addListener: jest.fn(() => ({ remove: jest.fn() })), + Events: { PUSH_NOTIFICATION_EVENT: 'push_notification_event' }, + }, +})); + jest.mock('react-native/Libraries/Image/resolveAssetSource', () => ({ __esModule: true, default: (source) => { diff --git a/builds.yml b/builds.yml index ba89a5c7d5c7..7edd2821e544 100644 --- a/builds.yml +++ b/builds.yml @@ -91,6 +91,10 @@ _secrets: &secrets # Infrastructure ANDROID_GOOGLE_SERVER_CLIENT_ID: 'ANDROID_GOOGLE_SERVER_CLIENT_ID' # Card/Baanx MM_CARD_BAANX_API_CLIENT_KEY: 'MM_CARD_BAANX_API_CLIENT_KEY' + # Braze + MM_BRAZE_API_KEY_IOS: 'MM_BRAZE_API_KEY_IOS' + MM_BRAZE_API_KEY_ANDROID: 'MM_BRAZE_API_KEY_ANDROID' + MM_BRAZE_SDK_ENDPOINT: 'MM_BRAZE_SDK_ENDPOINT' # Expo EXPO_PROJECT_ID: 'EXPO_PROJECT_ID' diff --git a/ios/MetaMask/AppDelegate.h b/ios/MetaMask/AppDelegate.h index af9811a4ab52..e1829ab99c01 100644 --- a/ios/MetaMask/AppDelegate.h +++ b/ios/MetaMask/AppDelegate.h @@ -3,8 +3,11 @@ #import #import +@class Braze; + @interface AppDelegate : EXAppDelegateWrapper @property (nonatomic, strong) UIWindow *window; +@property (class, strong, nonatomic) Braze *braze; @end diff --git a/ios/MetaMask/AppDelegate.m b/ios/MetaMask/AppDelegate.m index 4988e93c41c1..c236763636e3 100644 --- a/ios/MetaMask/AppDelegate.m +++ b/ios/MetaMask/AppDelegate.m @@ -5,10 +5,21 @@ #import #import +#import +#import "BrazeReactBridge.h" +static Braze *_braze = nil; @implementation AppDelegate ++ (Braze *)braze { + return _braze; +} + ++ (void)setBraze:(Braze *)braze { + _braze = braze; +} + - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { self.moduleName = @"MetaMask"; @@ -23,7 +34,7 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( foxCode = @"debug"; } - [RNBranch.branch checkPasteboardOnInstall]; + [RNBranch.branch checkPasteboardOnInstall]; // Uncomment this line to use the test key instead of the live one. // [RNBranch useTestInstance]; [RNBranch initSessionWithLaunchOptions:launchOptions isReferrable:YES]; @@ -31,6 +42,21 @@ - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:( // They will be passed down to the ViewController used by React Native. self.initialProps = @{@"foxCode": foxCode}; + // Setup Braze — credentials come from Info.plist (injected via MM_BRAZE_API_KEY_IOS / MM_BRAZE_SDK_ENDPOINT from .ios.env) + NSString *brazeApiKey = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"braze_api_key"]; + NSString *brazeEndpoint = [[NSBundle mainBundle] objectForInfoDictionaryKey:@"braze_sdk_endpoint"]; + if (brazeApiKey.length > 0 && brazeEndpoint.length > 0) { + BRZConfiguration *configuration = [[BRZConfiguration alloc] initWithApiKey:brazeApiKey + endpoint:brazeEndpoint]; + configuration.logger.level = BRZLoggerLevelInfo; + // push.automation handles APNs token registration and Braze-originated notification display. + // requestAuthorizationAtLaunch is NO so the existing permission flow (Firebase/Notifee) is preserved. + configuration.push.automation = [[BRZConfigurationPushAutomation alloc] initEnablingAllAutomations:YES]; + configuration.push.automation.requestAuthorizationAtLaunch = NO; + Braze *braze = [BrazeReactBridge initBraze:configuration]; + AppDelegate.braze = braze; + } + return [super application:application didFinishLaunchingWithOptions:launchOptions]; } @@ -54,7 +80,6 @@ - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(N return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options]; #endif return [RNBranch application:application openURL:url options:options]; - } // Universal Links @@ -83,4 +108,4 @@ - (void)application:(UIApplication *)application didReceiveRemoteNotification:(N return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler]; } -@end \ No newline at end of file +@end diff --git a/ios/MetaMask/Info.plist b/ios/MetaMask/Info.plist index 275874ff0612..9ed87e28cb23 100644 --- a/ios/MetaMask/Info.plist +++ b/ios/MetaMask/Info.plist @@ -145,5 +145,9 @@ $(MM_FOX_CODE) mixpanel_token $(MM_MIXPANEL_TOKEN) + braze_api_key + $(MM_BRAZE_API_KEY_IOS) + braze_sdk_endpoint + $(MM_BRAZE_SDK_ENDPOINT) diff --git a/ios/MetaMask/MetaMask-Flask-Info.plist b/ios/MetaMask/MetaMask-Flask-Info.plist index e7bd3c5bf78e..0543c26d779d 100644 --- a/ios/MetaMask/MetaMask-Flask-Info.plist +++ b/ios/MetaMask/MetaMask-Flask-Info.plist @@ -134,5 +134,9 @@ fox_code $(MM_FOX_CODE) + braze_api_key + $(MM_BRAZE_API_KEY_IOS) + braze_sdk_endpoint + $(MM_BRAZE_SDK_ENDPOINT) diff --git a/ios/MetaMask/MetaMask-QA-Info.plist b/ios/MetaMask/MetaMask-QA-Info.plist index 2b49cf8a0d46..75aa83c702de 100644 --- a/ios/MetaMask/MetaMask-QA-Info.plist +++ b/ios/MetaMask/MetaMask-QA-Info.plist @@ -120,5 +120,9 @@ $(MM_BRANCH_KEY_LIVE) fox_code $(MM_FOX_CODE) + braze_api_key + $(MM_BRAZE_API_KEY_IOS) + braze_sdk_endpoint + $(MM_BRAZE_SDK_ENDPOINT) diff --git a/ios/Podfile.lock b/ios/Podfile.lock index dcfa2445fe12..33f62ca1990a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3,6 +3,35 @@ PODS: - BEMCheckBox (1.4.1) - boost (1.84.0) - Branch (1.43.2) + - braze-react-native-sdk (19.1.0): + - BrazeKit (~> 14.0.1) + - BrazeLocation (~> 14.0.1) + - BrazeUI (~> 14.0.1) + - DoubleConversion + - glog + - hermes-engine + - RCT-Folly (= 2024.10.14.00) + - RCTRequired + - RCTTypeSafety + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-NativeModulesApple + - React-RCTFabric + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - Yoga + - BrazeKit (14.0.2) + - BrazeLocation (14.0.2): + - BrazeKit (= 14.0.2) + - BrazeUI (14.0.2): + - BrazeKit (= 14.0.2) - BVLinearGradient (2.8.3): - React-Core - CocoaAsyncSocket (7.6.5) @@ -2969,6 +2998,7 @@ PODS: DEPENDENCIES: - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) + - "braze-react-native-sdk (from `../node_modules/@braze/react-native-sdk`)" - BVLinearGradient (from `../node_modules/react-native-linear-gradient`) - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - EASClient (from `../node_modules/expo-eas-client/ios`) @@ -3136,6 +3166,9 @@ SPEC REPOS: - Base64 - BEMCheckBox - Branch + - BrazeKit + - BrazeLocation + - BrazeUI - CocoaAsyncSocket - Firebase - FirebaseCore @@ -3168,6 +3201,8 @@ SPEC REPOS: EXTERNAL SOURCES: boost: :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + braze-react-native-sdk: + :path: "../node_modules/@braze/react-native-sdk" BVLinearGradient: :path: "../node_modules/react-native-linear-gradient" DoubleConversion: @@ -3485,6 +3520,10 @@ SPEC CHECKSUMS: BEMCheckBox: 5ba6e37ade3d3657b36caecc35c8b75c6c2b1a4e boost: 1dca942403ed9342f98334bf4c3621f011aa7946 Branch: 4ac024cb3c29b0ef628048694db3c4cfa679beb0 + braze-react-native-sdk: 65cb601695ec808e3739227864bfcb76f67cafaa + BrazeKit: 737bca0f11642c9d9b962d7eb587e6fe1ce7262c + BrazeLocation: d3d2055b25d1a0e4ae10b1166a783e959317f0ca + BrazeUI: ec3eacaa39838b5ded7cfecd77d12b2e8ffea9c4 BVLinearGradient: cb006ba232a1f3e4f341bb62c42d1098c284da70 CocoaAsyncSocket: 065fd1e645c7abab64f7a6a2007a48038fdc6a99 DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 diff --git a/package.json b/package.json index 9e3b56428a41..7210f17dadb2 100644 --- a/package.json +++ b/package.json @@ -193,6 +193,7 @@ "@metamask/messenger@^0.3.0": "^1.0.0" }, "dependencies": { + "@braze/react-native-sdk": "patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch", "@config-plugins/detox": "^9.0.0", "@consensys/native-ramps-sdk": "^2.1.7", "@consensys/on-ramp-sdk": "2.1.12", diff --git a/scripts/build.sh b/scripts/build.sh index fbbe944d78bd..cc7fc90bc89a 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -719,6 +719,9 @@ createEnvFile() { "QUICKNODE_POLYGON_URL" "QUICKNODE_HYPEREVM_URL" "MM_CHARTING_LIBRARY_URL" + "MM_BRAZE_API_KEY_IOS" + "MM_BRAZE_API_KEY_ANDROID" + "MM_BRAZE_SDK_ENDPOINT" ) # Create .env file and export to GITHUB_ENV diff --git a/scripts/verify-build-config.js b/scripts/verify-build-config.js index 882e1d467f8d..0bfd6356ed83 100644 --- a/scripts/verify-build-config.js +++ b/scripts/verify-build-config.js @@ -73,6 +73,9 @@ const SECRETS_TO_VERIFY = [ // Other critical secrets 'MM_FOX_CODE', 'MM_BRANCH_KEY_LIVE', + 'MM_BRAZE_API_KEY_IOS', + 'MM_BRAZE_API_KEY_ANDROID', + 'MM_BRAZE_SDK_ENDPOINT', 'GOOGLE_SERVICES_B64_IOS', 'GOOGLE_SERVICES_B64_ANDROID', ]; diff --git a/yarn.lock b/yarn.lock index 6e5d302e8007..19619dcf6813 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2103,6 +2103,20 @@ __metadata: languageName: node linkType: hard +"@braze/react-native-sdk@npm:19.1.0": + version: 19.1.0 + resolution: "@braze/react-native-sdk@npm:19.1.0" + checksum: 10/2d279c2e40783eb73bd8d508e00cd36afec7da4bab3e0d34a0a5ef32cfd75badc2443428d37405b8e46f6d9eb959e1b9dd7e7ec6e555364b2e9c685f7ce2a63f + languageName: node + linkType: hard + +"@braze/react-native-sdk@patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch": + version: 19.1.0 + resolution: "@braze/react-native-sdk@patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch::version=19.1.0&hash=c50812" + checksum: 10/8d57b8d761d71bc2410eb1de640fd2c018617495f044a937b2f9c9589716d05800210ce5356087fba0e78d72f833e070cba5c89405b58447a4ab29cc8318b9ec + languageName: node + linkType: hard + "@callstack/reassure-cli@npm:1.4.0": version: 1.4.0 resolution: "@callstack/reassure-cli@npm:1.4.0" @@ -35522,6 +35536,7 @@ __metadata: "@babel/preset-env": "npm:^7.25.3" "@babel/register": "npm:^7.24.6" "@babel/runtime": "npm:^7.25.0" + "@braze/react-native-sdk": "patch:@braze/react-native-sdk@npm%3A19.1.0#~/.yarn/patches/@braze-react-native-sdk-npm-19.1.0-076-reactmoduleinfo.patch" "@config-plugins/detox": "npm:^9.0.0" "@consensys/native-ramps-sdk": "npm:^2.1.7" "@consensys/on-ramp-sdk": "npm:2.1.12" From 859f0002341412910ad78217b31ba3cdef1fb707 Mon Sep 17 00:00:00 2001 From: Bryan Fullam Date: Tue, 31 Mar 2026 12:41:58 +0200 Subject: [PATCH 5/8] fix: distinguish swaps trending token details source (#28128) ## **Description** This change separates swaps-trending token detail navigation from the generic explore trending flow so MetaMetrics can attribute those entry points correctly. It adds a dedicated `trending-swaps` token details source, passes that source from the Bridge/Swaps trending section into `TrendingTokenRowItem`, and keeps the default `trending` source for existing explore-tab behavior. The added test coverage verifies the swaps-trending navigation params include the new source. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: [SWAPS-4306](https://consensyssoftware.atlassian.net/browse/SWAPS-4306) ## **Manual testing steps** ```gherkin Feature: Swaps trending token source tracking Scenario: user opens token details from swaps trending Given the app is running and the Bridge/Swaps trending tokens section is visible When the user taps a token from the Bridge/Swaps trending tokens list Then the Asset route is opened for that token And the token details navigation params use the source "trending-swaps" Scenario: user opens token details from explore trending Given the app is running and the Explore trending tokens list is visible When the user taps a token from the Explore trending list Then the Asset route is opened for that token And the token details navigation params continue to use the source "trending" ``` ## **Screenshots/Recordings** ### **Before** N/A ### **After** N/A ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Small, scoped change to navigation params/analytics attribution with a default preserving existing behavior; minimal functional risk aside from potential analytics mislabeling if miswired. > > **Overview** > Routes opened from the Swaps/Bridge trending tokens section now pass a dedicated token-details `source` value (`TokenDetailsSource.TrendingSwaps`) instead of the generic trending source, allowing analytics to distinguish entry points. > > This introduces the new `trending-swaps` enum value, adds an optional `tokenDetailsSource` prop to `TrendingTokenRowItem` (defaulting to `Trending`), and updates tests to assert the Bridge section forwards the source and that navigation params include `source: 'trending-swaps'` when provided. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 6909921f5369b3d1957b216d6ffd119f9c66c327. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../BridgeTrendingTokensSection.test.tsx | 21 +++++- .../BridgeTrendingTokensSection.tsx | 2 + .../UI/TokenDetails/constants/constants.ts | 4 +- .../TrendingTokenRowItem.test.tsx | 66 +++++++++++++++++++ .../TrendingTokenRowItem.tsx | 18 ++++- 5 files changed, 105 insertions(+), 6 deletions(-) diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx index 1e6cbd56df7b..2b06e6f22aaa 100644 --- a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx +++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx @@ -4,8 +4,11 @@ import React from 'react'; import BridgeTrendingTokensSection from './BridgeTrendingTokensSection'; import { useTokenListFilters } from '../../../Trending/hooks/useTokenListFilters/useTokenListFilters'; import { useTrendingRequest } from '../../../Trending/hooks/useTrendingRequest/useTrendingRequest'; +import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import { BridgeTrendingTokensSectionTestIds } from './BridgeTrendingTokensSection.testIds'; +const mockTrendingTokenRowItem = jest.fn(); + jest.mock('react-redux', () => ({ useSelector: jest.fn(() => ({})), })); @@ -35,8 +38,16 @@ jest.mock( const { View } = jest.requireActual('react-native'); return { __esModule: true, - default: ({ token }: { token: { assetId: string } }) => - ReactLib.createElement(View, { testID: `row-${token.assetId}` }), + default: ({ + token, + tokenDetailsSource, + }: { + token: { assetId: string }; + tokenDetailsSource?: TokenDetailsSource; + }) => { + mockTrendingTokenRowItem({ token, tokenDetailsSource }); + return ReactLib.createElement(View, { testID: `row-${token.assetId}` }); + }, }; }, ); @@ -119,6 +130,12 @@ describe('BridgeTrendingTokensSection', () => { const rows = getAllByTestId(/^row-/); expect(rows).toHaveLength(12); + expect(mockTrendingTokenRowItem).toHaveBeenCalledTimes(12); + expect(mockTrendingTokenRowItem).toHaveBeenCalledWith( + expect.objectContaining({ + tokenDetailsSource: TokenDetailsSource.TrendingSwaps, + }), + ); expect( getByTestId(BridgeTrendingTokensSectionTestIds.SHOW_MORE), ).toBeTruthy(); diff --git a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx index fcc21b09e8e6..cc8a14b991fa 100644 --- a/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx +++ b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.tsx @@ -32,6 +32,7 @@ import { NETWORK_TO_SHORT_NETWORK_NAME_MAP } from '../../../../../constants/brid import type { ProcessedNetwork } from '../../../../hooks/useNetworksByNamespace/useNetworksByNamespace'; import type { CaipChainId } from '@metamask/utils'; import { FilterButton } from '../../../Trending/components/FilterBar/FilterBar'; +import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; import { BridgeTrendingTokensSectionTestIds } from './BridgeTrendingTokensSection.testIds'; const TOKEN_CHUNK_SIZE = 12; @@ -188,6 +189,7 @@ const BridgeTrendingTokensSection = ({ position={index} selectedTimeOption={selectedTimeOption} filterContext={filterContext} + tokenDetailsSource={TokenDetailsSource.TrendingSwaps} /> ))} {!isLoading && hasMore ? ( diff --git a/app/components/UI/TokenDetails/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index 0c7a8739f3b9..711f304c5e13 100644 --- a/app/components/UI/TokenDetails/constants/constants.ts +++ b/app/components/UI/TokenDetails/constants/constants.ts @@ -11,8 +11,10 @@ export enum TokenDetailsSource { MobileTokenListPage = 'mobile-token-list-page', /** Homepage section entry point */ HomeSection = 'home_section', - /** Trending tokens section */ + /** Trending tokens section (e.g. Explore tab) */ Trending = 'trending', + /** Trending tokens section on the Swaps / Bridge view */ + TrendingSwaps = 'trending-swaps', /** Swap/Bridge token selector */ Swap = 'swap', /** Fallback when source cannot be determined */ diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index 1d073d269f0f..2f9a089faf38 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx @@ -6,6 +6,7 @@ import TrendingTokenRowItem from './TrendingTokenRowItem'; import type { TrendingAsset } from '@metamask/assets-controllers'; import { TimeOption, PriceChangeOption } from '../TrendingTokensBottomSheet'; import type { TrendingFilterContext } from '../TrendingTokensList/TrendingTokensList'; +import { TokenDetailsSource } from '../../../TokenDetails/constants/constants'; // Mock the trendingNetworksList module to avoid getNetworkImageSource errors jest.mock('../../utils/trendingNetworksList', () => ({ @@ -816,6 +817,71 @@ describe('TrendingTokenRowItem', () => { ); }); + it('navigates with tokenDetailsSource TrendingSwaps for Swaps trending analytics', () => { + const token = createMockToken({ + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + }); + + const networkAddedState = { + ...mockState, + engine: { + ...mockState.engine, + backgroundState: { + ...mockState.engine.backgroundState, + NetworkController: { + networkConfigurations: {}, + networkConfigurationsByChainId: { + '0x1': { + chainId: '0x1', + caipChainId: 'eip155:1', + name: 'Ethereum Mainnet', + }, + }, + }, + MultichainNetworkController: { + ...mockState.engine.backgroundState.MultichainNetworkController, + multichainNetworkConfigurationsByChainId: {}, + }, + }, + }, + }; + + const { getByTestId } = renderWithProvider( + , + { state: networkAddedState }, + false, + ); + + const tokenRow = getByTestId( + 'trending-token-row-item-eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + ); + fireEvent.press(tokenRow); + + expect(mockDispatch).toHaveBeenCalledWith( + StackActions.push('Asset', { + chainId: '0x1', + address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.png', + pricePercentChange1d: 3.44, + isNative: false, + isETH: false, + isFromTrending: true, + rwaData: undefined, + source: 'trending-swaps', + }), + ); + }); + it('navigates to Asset page with isETH true for native ETH on Ethereum mainnet', () => { const token = createMockToken({ assetId: 'eip155:1/slip44:60', diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx index c6aead4e767c..a3ea2e3e3448 100644 --- a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx +++ b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.tsx @@ -131,12 +131,20 @@ interface TrendingTokenRowItemProps { position?: number; /** Filter context for analytics tracking */ filterContext?: TrendingFilterContext; + /** + * Token Details `source` for MetaMetrics (e.g. Explore trending vs Swaps trending). + * @default TokenDetailsSource.Trending + */ + tokenDetailsSource?: TokenDetailsSource; } /** * Converts a TrendingAsset to Asset navigation params */ -const getAssetNavigationParams = (token: TrendingAsset) => { +const getAssetNavigationParams = ( + token: TrendingAsset, + source: TokenDetailsSource, +) => { const [caipChainId, assetIdentifier] = token.assetId.split('/'); if (!isCaipChainId(caipChainId)) return null; @@ -161,7 +169,7 @@ const getAssetNavigationParams = (token: TrendingAsset) => { isNative: isNativeToken, isETH: isNativeToken && hexChainId === '0x1', isFromTrending: true, - source: TokenDetailsSource.Trending, + source, rwaData: token.rwaData, securityData: token.securityData, }; @@ -172,6 +180,7 @@ const TrendingTokenRowItem = ({ selectedTimeOption = TimeOption.TwentyFourHours, position, filterContext, + tokenDetailsSource = TokenDetailsSource.Trending, }: TrendingTokenRowItemProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); @@ -187,7 +196,10 @@ const TrendingTokenRowItem = ({ [token.assetId], ); - const assetParams = useMemo(() => getAssetNavigationParams(token), [token]); + const assetParams = useMemo( + () => getAssetNavigationParams(token, tokenDetailsSource), + [token, tokenDetailsSource], + ); const networkBadgeImageSource = useMemo( () => getNetworkBadgeSource(caipChainId), From 44e69f30a213f51940e25e341a71e15f00389b31 Mon Sep 17 00:00:00 2001 From: Vinicius Stevam <45455812+vinistevam@users.noreply.github.com> Date: Tue, 31 Mar 2026 11:45:41 +0100 Subject: [PATCH 6/8] refactor: remove multichain accounts State 2 feature flag from confirmations (#28136) ## **Description** Removes `selectMultichainAccountsState2Enabled` selector usage from the confirmations area now that the multichain accounts State 2 / BIP-44 feature is considered stable. The legacy code path (when the flag returned `false`) is no longer needed. Changes: - Simplified `useSendScope` hook to always return State 2 behavior (`isBIP44: true`, `isSolanaOnly: false`, `isEvmOnly: false`), removing the `selectMultichainAccountsState2Enabled` and `selectSelectedInternalAccount` selector dependencies. - Removed `isBIP44` branching in `RecipientList` -- accounts always render as BIP44-grouped list; contacts always render as flat list. - Removed `isBIP44` prop from `Recipient` component -- always displays `accountGroupName` (State 2 behavior) instead of conditionally choosing between `accountGroupName` and `accountName`. - Removed dead `isSolanaOnly` early-return in `useEVMNfts` hook (always `false` under State 2). - Cleaned up 7 test files: removed selector mocks, deleted legacy-path test suites, updated mock data. ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/CONF-1088 ## **Manual testing steps** ```gherkin Feature: Confirmation screens after removing multichain State 2 feature flag Scenario: Send flow shows grouped recipient list Given the user has multiple wallets with accounts When the user navigates to the Send screen And selects a recipient from the account list Then the accounts are grouped by wallet name And each account displays its account group name Scenario: Send flow shows flat contact list Given the user has saved contacts When the user navigates to the Send screen And switches to the Contacts tab Then the contacts are displayed in a flat list under a "Contacts" header Scenario: Personal sign confirmation renders correctly Given the user has a pending personal_sign request When the user views the confirmation screen Then the request origin, message, and account info are displayed correctly Scenario: Typed sign confirmation renders correctly Given the user has a pending eth_signTypedData request When the user views the confirmation screen Then the typed data fields and network info are displayed correctly Scenario: Approve/send transaction confirmation renders correctly Given the user initiates a token send or approve transaction When the user views the confirmation screen Then the sender account info, network, and transaction details are displayed correctly ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Removes feature-flag/legacy branching in confirmation send UI and NFT hook behavior, which can change what names/lists render in edge cases. Mostly refactor/deletion, but touches user-facing recipient display and list grouping. > > **Overview** > **Confirmations no longer branch on the multichain State 2/BIP-44 feature flag.** Recipient UI is simplified to always display `accountGroupName` (falling back to `contactName`) and drops the `isBIP44` prop. > > Send recipient lists now always group accounts by `walletName` (BIP-44-style) while contact lists stay flat with a fixed "Contacts" header, and the `useSendScope` hook and its tests are removed. `useEVMNfts` also drops the Solana-only early-return, with tests updated/trimmed to reflect the new single-path behavior and removed selector mocks. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 7dceb226036105e2a81668c68f0a4436cfe07770. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../info-value/address/address.test.tsx | 7 - .../UI/recipient/recipient.test.tsx | 25 +- .../components/UI/recipient/recipient.tsx | 6 +- .../info/personal-sign/personal-sign.test.tsx | 7 - .../recipient-list/recipient-list.test.tsx | 91 ++------ .../recipient-list/recipient-list.tsx | 14 +- .../account-network-info-row.test.tsx | 7 - .../confirmations/hooks/send/useNfts.test.tsx | 37 +-- .../Views/confirmations/hooks/send/useNfts.ts | 6 - .../hooks/send/useSendScope.test.ts | 220 ------------------ .../confirmations/hooks/send/useSendScope.ts | 38 --- 11 files changed, 43 insertions(+), 415 deletions(-) delete mode 100644 app/components/Views/confirmations/hooks/send/useSendScope.test.ts delete mode 100644 app/components/Views/confirmations/hooks/send/useSendScope.ts diff --git a/app/components/Views/confirmations/components/UI/info-row/info-value/address/address.test.tsx b/app/components/Views/confirmations/components/UI/info-row/info-value/address/address.test.tsx index e66f2ad5f893..ed4af15eec13 100644 --- a/app/components/Views/confirmations/components/UI/info-row/info-value/address/address.test.tsx +++ b/app/components/Views/confirmations/components/UI/info-row/info-value/address/address.test.tsx @@ -11,13 +11,6 @@ import Address from './address'; import { CHAIN_IDS } from '@metamask/transaction-controller'; import { NameType } from '../../../../../../../UI/Name/Name.types'; -jest.mock( - '../../../../../../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - const mockInitialState = { engine: { backgroundState: { diff --git a/app/components/Views/confirmations/components/UI/recipient/recipient.test.tsx b/app/components/Views/confirmations/components/UI/recipient/recipient.test.tsx index 2c1f00738d38..c50d75edb56c 100644 --- a/app/components/Views/confirmations/components/UI/recipient/recipient.test.tsx +++ b/app/components/Views/confirmations/components/UI/recipient/recipient.test.tsx @@ -10,7 +10,7 @@ describe('Recipient', () => { const createMockRecipient = ( overrides: Partial = {}, ): RecipientType => ({ - accountName: 'John Doe', + accountGroupName: 'John Doe', address: '0x1234567890123456789012345678901234567890', ...overrides, }); @@ -23,7 +23,7 @@ describe('Recipient', () => { it('renders recipient name correctly', () => { const mockRecipient = createMockRecipient({ - accountName: 'Alice Smith', + accountGroupName: 'Alice Smith', }); const { getByText } = renderWithProvider( @@ -111,7 +111,7 @@ describe('Recipient', () => { expect(getByText('0x12345...67890')).toBeOnTheScreen(); }); - it('renders contact name when BIP44 is true and account group name is not provided', () => { + it('renders contact name when account group name is not provided', () => { const mockRecipient = createMockRecipient({ accountGroupName: undefined, contactName: 'Contact Name', @@ -120,7 +120,6 @@ describe('Recipient', () => { const { getByText } = renderWithProvider( , @@ -129,8 +128,26 @@ describe('Recipient', () => { expect(getByText('Contact Name')).toBeOnTheScreen(); }); + it('renders account group name when provided', () => { + const mockRecipient = createMockRecipient({ + accountGroupName: 'My Wallet', + contactName: 'Contact Name', + }); + + const { getByText } = renderWithProvider( + , + ); + + expect(getByText('My Wallet')).toBeOnTheScreen(); + }); + it('renders BTC account type label when account type is BTC', () => { const mockRecipient = createMockRecipient({ + accountGroupName: 'BTC Wallet', accountType: BtcAccountType.P2wpkh, }); diff --git a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx index b8555735c994..248e714f5e0c 100644 --- a/app/components/Views/confirmations/components/UI/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/UI/recipient/recipient.tsx @@ -36,7 +36,6 @@ export interface RecipientType { interface RecipientProps { recipient: RecipientType; isSelected?: boolean; - isBIP44?: boolean; accountAvatarType: AvatarAccountType; onPress?: (recipient: RecipientType) => void; } @@ -44,7 +43,6 @@ interface RecipientProps { export function Recipient({ recipient, isSelected, - isBIP44, accountAvatarType, onPress, }: RecipientProps) { @@ -107,9 +105,7 @@ export function Recipient({ fontWeight={FontWeight.Medium} numberOfLines={1} > - {isBIP44 - ? recipient.accountGroupName || recipient.contactName - : recipient.accountName || recipient.contactName} + {recipient.accountGroupName || recipient.contactName} ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - jest.mock('../../../../../../core/Engine', () => ({ getTotalEvmFiatAccountBalance: () => ({ tokenFiat: 10 }), context: { diff --git a/app/components/Views/confirmations/components/recipient-list/recipient-list.test.tsx b/app/components/Views/confirmations/components/recipient-list/recipient-list.test.tsx index 01fc456b582e..ebf5ddf437e0 100644 --- a/app/components/Views/confirmations/components/recipient-list/recipient-list.test.tsx +++ b/app/components/Views/confirmations/components/recipient-list/recipient-list.test.tsx @@ -13,10 +13,6 @@ jest.mock('../../context/send-context/send-context', () => ({ useSendContext: jest.fn(), })); -jest.mock('../../hooks/send/useSendScope', () => ({ - useSendScope: jest.fn(), -})); - jest.mock('../../../../../../locales/i18n', () => ({ strings: jest.fn((key: string) => { const map: Record = { @@ -27,7 +23,6 @@ jest.mock('../../../../../../locales/i18n', () => ({ }), })); -// Mock child Recipient to keep tests deterministic and focused on grouping logic jest.mock('../UI/recipient', () => ({ // eslint-disable-next-line @typescript-eslint/no-explicit-any Recipient: ({ recipient, isSelected, onPress }: any) => { @@ -54,7 +49,6 @@ jest.mock('../UI/recipient', () => ({ }, })); -const { useSendScope } = jest.requireMock('../../hooks/send/useSendScope'); const { useSendContext } = jest.requireMock( '../../context/send-context/send-context', ); @@ -80,13 +74,11 @@ describe('RecipientList - BIP44 grouping', () => { }, { address: '0x4444444444444444444444444444444444444444', - // no walletName to trigger Unknown Wallet grouping }, ]; beforeEach(() => { jest.clearAllMocks(); - useSendScope.mockReturnValue({ isBIP44: true }); useSendContext.mockReturnValue({ to: '0x2222222222222222222222222222222222222222', }); @@ -97,7 +89,6 @@ describe('RecipientList - BIP44 grouping', () => { , ); - // Group headers expect(getByText('Wallet A')).toBeOnTheScreen(); expect(getByText('Wallet B')).toBeOnTheScreen(); expect(getByText('Unknown Wallet')).toBeOnTheScreen(); @@ -146,89 +137,35 @@ describe('RecipientList - BIP44 grouping', () => { ); expect(onRecipientSelected).not.toHaveBeenCalled(); }); -}); - -describe('RecipientList - non-BIP44 (flat list)', () => { - const onRecipientSelected = jest.fn(); - - const flatData: RecipientType[] = [ - { - address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - accountName: 'Account 1', - }, - { - address: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - accountName: 'Account 2', - }, - ]; - beforeEach(() => { - jest.clearAllMocks(); - useSendScope.mockReturnValue({ isBIP44: false }); - useSendContext.mockReturnValue({ - to: '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', - }); - }); - - it('renders accounts header and no wallet group headers', () => { - const { getByText, queryByText } = renderWithProvider( + it('renders empty state when data is empty and emptyMessage provided', () => { + const { getByText } = renderWithProvider( , ); - expect(getByText('Your Accounts')).toBeOnTheScreen(); - expect(queryByText('Wallet A')).toBeNull(); - expect(queryByText('Wallet B')).toBeNull(); - expect(queryByText('Unknown Wallet')).toBeNull(); + expect(getByText('No recipients')).toBeOnTheScreen(); }); - it('marks selection and calls onRecipientSelected on press', () => { - const { getByTestId } = renderWithProvider( - , - ); - - expect( - getByTestId('selected-0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'), - ).toBeOnTheScreen(); - fireEvent.press( - getByTestId('recipient-0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), - ); - expect(onRecipientSelected).toHaveBeenCalledWith( - expect.objectContaining({ + it('renders contacts header for contact lists', () => { + const contactData: RecipientType[] = [ + { address: '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', - }), - ); - }); + contactName: 'Alice', + }, + ]; - it('does not call onRecipientSelected when disabled', () => { - const { getByTestId } = renderWithProvider( - , - ); - - fireEvent.press( - getByTestId('recipient-0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), - ); - expect(onRecipientSelected).not.toHaveBeenCalled(); - }); - - it('renders empty state when data is empty and emptyMessage provided', () => { const { getByText } = renderWithProvider( , ); - expect(getByText('No recipients')).toBeOnTheScreen(); + expect(getByText('Contacts')).toBeOnTheScreen(); }); }); diff --git a/app/components/Views/confirmations/components/recipient-list/recipient-list.tsx b/app/components/Views/confirmations/components/recipient-list/recipient-list.tsx index d7ed0ecc6309..0842218d2ddd 100644 --- a/app/components/Views/confirmations/components/recipient-list/recipient-list.tsx +++ b/app/components/Views/confirmations/components/recipient-list/recipient-list.tsx @@ -10,7 +10,6 @@ import { import { useAccountAvatarType } from '../../hooks/useAccountAvatarType'; import { useSendContext } from '../../context/send-context/send-context'; import { Recipient, type RecipientType } from '../UI/recipient'; -import { useSendScope } from '../../hooks/send/useSendScope'; import { AvatarAccountType } from '../../../../../component-library/components/Avatars/Avatar/variants/AvatarAccount'; import { strings } from '../../../../../../locales/i18n'; @@ -31,7 +30,6 @@ export function RecipientList({ }: RecipientListProps) { const accountAvatarType = useAccountAvatarType(); const { to } = useSendContext(); - const { isBIP44 } = useSendScope(); if (data.length === 0 && emptyMessage) { return ( @@ -41,7 +39,7 @@ export function RecipientList({ ); } - if (isContactList || !isBIP44) { + if (isContactList) { return ( - {strings(isContactList ? 'send.contacts' : 'send.accounts')} + {strings('send.contacts')} @@ -71,7 +68,6 @@ export function RecipientList({ onRecipientSelected={onRecipientSelected} accountAvatarType={accountAvatarType} to={to} - isBIP44={isBIP44} disabled={disabled} /> @@ -83,14 +79,12 @@ function FlatRecipientList({ onRecipientSelected, accountAvatarType, to, - isBIP44, disabled, }: { data: RecipientType[]; onRecipientSelected: (recipient: RecipientType) => void; accountAvatarType: AvatarAccountType; to?: string; - isBIP44?: boolean; disabled?: boolean; }) { return ( @@ -101,7 +95,6 @@ function FlatRecipientList({ recipient={recipient} accountAvatarType={accountAvatarType} isSelected={to === recipient.address} - isBIP44={isBIP44} onPress={disabled ? undefined : onRecipientSelected} /> ))} @@ -114,14 +107,12 @@ function BIP44RecipientList({ onRecipientSelected, accountAvatarType, to, - isBIP44, disabled, }: { data: RecipientType[]; onRecipientSelected: (recipient: RecipientType) => void; accountAvatarType: AvatarAccountType; to?: string; - isBIP44?: boolean; disabled?: boolean; }) { const groupedData = useMemo( @@ -158,7 +149,6 @@ function BIP44RecipientList({ recipient={recipient} accountAvatarType={accountAvatarType} isSelected={to === recipient.address} - isBIP44={isBIP44} onPress={disabled ? undefined : onRecipientSelected} /> ))} diff --git a/app/components/Views/confirmations/components/rows/account-network-info-row/account-network-info-row.test.tsx b/app/components/Views/confirmations/components/rows/account-network-info-row/account-network-info-row.test.tsx index 57b141602c93..5ad9b1b9b6e3 100644 --- a/app/components/Views/confirmations/components/rows/account-network-info-row/account-network-info-row.test.tsx +++ b/app/components/Views/confirmations/components/rows/account-network-info-row/account-network-info-row.test.tsx @@ -49,13 +49,6 @@ jest.mock('../../../../../../core/Engine', () => { }; }); -jest.mock( - '../../../../../../selectors/featureFlagController/multichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - describe('AccountNetworkInfo', () => { it('should render correctly', async () => { const { getByText, getByTestId } = renderWithProvider( diff --git a/app/components/Views/confirmations/hooks/send/useNfts.test.tsx b/app/components/Views/confirmations/hooks/send/useNfts.test.tsx index 716f0029eb72..b1035071e15f 100644 --- a/app/components/Views/confirmations/hooks/send/useNfts.test.tsx +++ b/app/components/Views/confirmations/hooks/send/useNfts.test.tsx @@ -23,7 +23,6 @@ import { selectInternalAccountsById } from '../../../../../selectors/accountsCon import { selectAllNfts } from '../../../../../selectors/nftController'; import { getNetworkBadgeSource } from '../../utils/network'; import { useEVMNfts } from './useNfts'; -import { useSendScope } from './useSendScope'; jest.mock('ethers/lib/utils', () => ({ isAddress: jest.fn(), @@ -48,7 +47,6 @@ jest.mock('../../../../../selectors/multichainAccounts/accountTreeController'); jest.mock('../../../../../selectors/accountsController'); jest.mock('../../../../../selectors/nftController'); jest.mock('../../utils/network'); -jest.mock('./useSendScope'); const mockIsEvmAddress = isEvmAddress as jest.MockedFunction< typeof isEvmAddress @@ -67,9 +65,6 @@ const mockSelectAllNfts = selectAllNfts as jest.MockedFunction< const mockGetNetworkBadgeSource = getNetworkBadgeSource as jest.MockedFunction< typeof getNetworkBadgeSource >; -const mockuseSendScope = useSendScope as jest.MockedFunction< - typeof useSendScope ->; const mockGetFormattedIpfsUrl = getFormattedIpfsUrl as jest.MockedFunction< typeof getFormattedIpfsUrl >; @@ -220,11 +215,6 @@ describe('useEVMNfts', () => { jest.clearAllMocks(); mockGetNetworkBadgeSource.mockReturnValue('network-badge-source'); mockIsEvmAddress.mockReturnValue(true); - mockuseSendScope.mockReturnValue({ - isSolanaOnly: false, - isEvmOnly: true, - isBIP44: false, - }); mockNetworkController.findNetworkClientIdByChainId.mockReturnValue( 'network-client-id', ); @@ -235,33 +225,16 @@ describe('useEVMNfts', () => { mockGetFormattedIpfsUrl.mockResolvedValue(undefined as unknown as string); }); - it('returns empty array when isEvm is false', async () => { - mockuseSendScope.mockReturnValue({ - isSolanaOnly: false, - isEvmOnly: false, - isBIP44: false, - }); - - mockSelectSelectedAccountGroup.mockReturnValue( - createMockAccountGroup(['account-1']), - ); - mockSelectInternalAccountsById.mockReturnValue( - createMockInternalAccountsById({ - 'account-1': mockAccount, - }), - ); - mockSelectAllNfts.mockReturnValue( - createMockAllNfts({ - [mockAccount.address]: { - '0x1': [mockNft], - }, - }), - ); + it('returns empty array when no account group is selected', async () => { + mockSelectSelectedAccountGroup.mockReturnValue(null); + mockSelectInternalAccountsById.mockReturnValue({}); + mockSelectAllNfts.mockReturnValue({}); const { result } = renderHookWithStore(() => useEVMNfts()); await waitFor(() => { expect(result.current.nfts).toEqual([]); + expect(result.current.isLoading).toBe(false); }); }); diff --git a/app/components/Views/confirmations/hooks/send/useNfts.ts b/app/components/Views/confirmations/hooks/send/useNfts.ts index 7bb10c34a9f3..a0ddb3cf0437 100644 --- a/app/components/Views/confirmations/hooks/send/useNfts.ts +++ b/app/components/Views/confirmations/hooks/send/useNfts.ts @@ -9,7 +9,6 @@ import { selectInternalAccountsById } from '../../../../../selectors/accountsCon import { selectAllNfts } from '../../../../../selectors/nftController'; import { getNetworkBadgeSource } from '../../utils/network'; import { Nft } from '../../types/token'; -import { useSendScope } from './useSendScope'; import { getFormattedIpfsUrl } from '@metamask/assets-controllers'; import useIpfsGateway from '../../../../hooks/useIpfsGateway'; import Logger from '../../../../../util/Logger'; @@ -27,7 +26,6 @@ export function useEVMNfts(): UseEVMNftsResult { const allNFTS = useSelector(selectAllNfts); const [transformedNfts, setTransformedNfts] = useState([]); const [isLoading, setIsLoading] = useState(true); - const { isSolanaOnly } = useSendScope(); const ipfsGateway = useIpfsGateway(); const evmAccount = selectedAccountGroup?.accounts @@ -108,10 +106,6 @@ export function useEVMNfts(): UseEVMNftsResult { NetworkController, ]); - if (isSolanaOnly) { - return { nfts: [], isLoading: false }; - } - return { nfts: transformedNfts, isLoading }; } diff --git a/app/components/Views/confirmations/hooks/send/useSendScope.test.ts b/app/components/Views/confirmations/hooks/send/useSendScope.test.ts deleted file mode 100644 index 10da6d7d8305..000000000000 --- a/app/components/Views/confirmations/hooks/send/useSendScope.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { renderHookWithProvider } from '../../../../../util/test/renderWithProvider'; -import { useSendScope } from './useSendScope'; - -jest.mock( - '../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts', - () => ({ - selectMultichainAccountsState2Enabled: () => false, - }), -); - -describe('useSendScope', () => { - const mockEvmAccount = { - id: 'evm-account-1', - address: '0x1234567890123456789012345678901234567890', - type: 'eip155:eoa' as const, - metadata: { - name: 'EVM Account', - keyring: { - type: 'HD Key Tree', - }, - }, - }; - - const mockSolanaAccount = { - id: 'solana-account-1', - address: 'Sol1234567890123456789012345678901234567890', - type: 'solana:data-account' as const, - metadata: { - name: 'Solana Account', - keyring: { - type: 'Solana Keyring', - }, - }, - }; - - const mockUnknownAccount = { - id: 'unknown-account-1', - address: 'unknown1234567890123456789012345678901234567890', - type: 'bitcoin:legacy', - metadata: { - name: 'Unknown Account', - keyring: { - type: 'Bitcoin Keyring', - }, - }, - }; - - it('returns false flags when no account is selected', () => { - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: {}, - selectedAccount: '', - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - state, - }); - - expect(result.current).toEqual({ - isSolanaOnly: false, - isEvmOnly: false, - isBIP44: false, - }); - }); - - it('returns isSolanaOnly true for solana account type', () => { - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: { - [mockSolanaAccount.id]: mockSolanaAccount, - }, - selectedAccount: mockSolanaAccount.id, - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - state, - }); - - expect(result.current).toEqual({ - isSolanaOnly: true, - isEvmOnly: false, - isBIP44: false, - }); - }); - - it('returns isEvmOnly true for eip155 account type', () => { - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: { - [mockEvmAccount.id]: mockEvmAccount, - }, - selectedAccount: mockEvmAccount.id, - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - state, - }); - - expect(result.current).toEqual({ - isSolanaOnly: false, - isEvmOnly: true, - isBIP44: false, - }); - }); - - it('returns false flags for unknown account type', () => { - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: { - [mockUnknownAccount.id]: mockUnknownAccount, - }, - selectedAccount: mockUnknownAccount.id, - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: state as any, - }); - - expect(result.current).toEqual({ - isSolanaOnly: false, - isEvmOnly: false, - isBIP44: false, - }); - }); - - it('handles account type containing solana substring', () => { - const solanaVariantAccount = { - ...mockSolanaAccount, - type: 'solana:custom-variant', - }; - - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: { - [solanaVariantAccount.id]: solanaVariantAccount, - }, - selectedAccount: solanaVariantAccount.id, - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: state as any, - }); - - expect(result.current).toEqual({ - isSolanaOnly: true, - isEvmOnly: false, - isBIP44: false, - }); - }); - - it('handles account type containing eip155 substring', () => { - const evmVariantAccount = { - ...mockEvmAccount, - type: 'eip155:custom-variant', - }; - - const state = { - engine: { - backgroundState: { - AccountsController: { - internalAccounts: { - accounts: { - [evmVariantAccount.id]: evmVariantAccount, - }, - selectedAccount: evmVariantAccount.id, - }, - }, - }, - }, - }; - - const { result } = renderHookWithProvider(() => useSendScope(), { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: state as any, - }); - - expect(result.current).toEqual({ - isSolanaOnly: false, - isEvmOnly: true, - isBIP44: false, - }); - }); -}); diff --git a/app/components/Views/confirmations/hooks/send/useSendScope.ts b/app/components/Views/confirmations/hooks/send/useSendScope.ts deleted file mode 100644 index 4003b878be0c..000000000000 --- a/app/components/Views/confirmations/hooks/send/useSendScope.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { useSelector } from 'react-redux'; -import { selectSelectedInternalAccount } from '../../../../../selectors/accountsController'; -import { selectMultichainAccountsState2Enabled } from '../../../../../selectors/featureFlagController/multichainAccounts/enabledMultichainAccounts'; - -interface SendScope { - isSolanaOnly?: boolean; - isEvmOnly?: boolean; - isBIP44?: boolean; -} - -export function useSendScope(): SendScope { - const selectedAccount = useSelector(selectSelectedInternalAccount); - const isMultichainAccountsState2Enabled = useSelector( - selectMultichainAccountsState2Enabled, - ); - - const sendScope: SendScope = { - isSolanaOnly: false, - isEvmOnly: false, - isBIP44: false, - }; - - if (isMultichainAccountsState2Enabled) { - return { - isBIP44: true, - isSolanaOnly: false, - isEvmOnly: false, - }; - } - - if (selectedAccount?.type?.includes('solana')) { - sendScope.isSolanaOnly = true; - } else if (selectedAccount?.type?.includes('eip155')) { - sendScope.isEvmOnly = true; - } - - return sendScope; -} From 6bb954e51cdbd7fde38b9e1dc9e6fd4a66633711 Mon Sep 17 00:00:00 2001 From: Michele Esposito <34438276+mikesposito@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:47:27 +0200 Subject: [PATCH 7/8] chore: bump `@metamask/tron-wallet-snap` to `^1.25.0` (#27922) ## **Description** `@metamask/tron-wallet-snap` has been updated to `^1.25.0` to include the following changes: ```markdown ## [1.25.0] ### Changed - Optimize account discovery by using a lightweight activity check (`limit=1`) instead of fetching full transaction history ([#252](https://github.com/MetaMask/snap-tron-wallet/pull/252)) ### Fixed - Avoid `onAmountInput` fee estimation failures by skipping fee validation until a recipient address is available ([#259](https://github.com/MetaMask/snap-tron-wallet/pull/259)) - Assert transaction structure at all entry points, rejecting malformed transactions ([#237](https://github.com/MetaMask/snap-tron-wallet/pull/237)) - Disable scanning of unsupported contract types, preventing incorrect security alerts from blocking user flows ([#238](https://github.com/MetaMask/snap-tron-wallet/pull/238)) - Supported transactions are those single-contract interaction transactions of the following types: `TransferContract`, `CreateSmartContract`, `TriggerSmartContract`. - Unsupported transactions will show empty estimated changes and allow the user to proceed without blocking the confirmation. - Correctly fetch and return staking rewards ([#242](https://github.com/MetaMask/snap-tron-wallet/pull/242)) - Fix revert simulation error when sending TRC20 tokens ([#261](https://github.com/MetaMask/snap-tron-wallet/pull/261)) - Fix infinite loading during fee estimation ([#258](https://github.com/MetaMask/snap-tron-wallet/pull/258)) - The issue was caused by a deadlock during cache updates of chain parameters. ``` ## **Changelog** CHANGELOG entry: Fixed a bug that was causing issues with TRC20 token transfers ## **Related issues** Fixes: ## **Manual testing steps** 1. Initiate a TRC20 token transfer (e.g., USDT) 2. Type in a valid recipient address and amount 3. Observe that the button does not show an error 4. Click the button to proceed with the transfer 5. Observe that the confirmation screen is shown with no simulation error ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/user-attachments/assets/e7b55fc3-9e80-4b44-8558-4cbbd9d38f62 ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Low Risk** > Dependency-only version bump with no direct code changes in this repo; risk is limited to any upstream behavioral changes in the TRON snap affecting TRON-specific flows. > > **Overview** > Bumps `@metamask/tron-wallet-snap` from `1.24.0` to `^1.25.0` in `package.json`, updating the resolved version to `1.25.0` in `yarn.lock`. > > No application code changes; this is a dependency-only update intended to pick up upstream fixes and behavior changes in the TRON snap. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 0a088858df76957bc8a8534d2c7a9dc022e756af. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- package.json | 2 +- yarn.lock | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 7210f17dadb2..c24a521871d9 100644 --- a/package.json +++ b/package.json @@ -314,7 +314,7 @@ "@metamask/swappable-obj-proxy": "^2.1.0", "@metamask/transaction-controller": "^63.3.0", "@metamask/transaction-pay-controller": "^17.1.0", - "@metamask/tron-wallet-snap": "1.24.0", + "@metamask/tron-wallet-snap": "^1.25.0", "@metamask/utils": "^11.8.1", "@myx-trade/sdk": "^0.1.265", "@ngraveio/bc-ur": "^1.1.6", diff --git a/yarn.lock b/yarn.lock index 19619dcf6813..c277962078bb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10230,10 +10230,10 @@ __metadata: languageName: node linkType: hard -"@metamask/tron-wallet-snap@npm:1.24.0": - version: 1.24.0 - resolution: "@metamask/tron-wallet-snap@npm:1.24.0" - checksum: 10/a081a52b24da36cd060413d07549e28ab3222a869e8a7c4a3e41cf0b09b89d7f90a482fd9d154a837afe1c07f229f931ebdc31f9fc307bbf7b1bfa97581a9bd8 +"@metamask/tron-wallet-snap@npm:^1.25.0": + version: 1.25.0 + resolution: "@metamask/tron-wallet-snap@npm:1.25.0" + checksum: 10/f87a48d2c21b9ea72dfba368ef2a66c73e692d7dfadde60e2606d7f631defcf9b6481c23e2ce17404028c58906c9245e8a60e22921155dd0f747d8330dd2c118 languageName: node linkType: hard @@ -35679,7 +35679,7 @@ __metadata: "@metamask/test-dapp-solana": "npm:^0.3.0" "@metamask/transaction-controller": "npm:^63.3.0" "@metamask/transaction-pay-controller": "npm:^17.1.0" - "@metamask/tron-wallet-snap": "npm:1.24.0" + "@metamask/tron-wallet-snap": "npm:^1.25.0" "@metamask/utils": "npm:^11.8.1" "@myx-trade/sdk": "npm:^0.1.265" "@ngraveio/bc-ur": "npm:^1.1.6" From c5c462321027eddd372c33c87c87288ed1314a4e Mon Sep 17 00:00:00 2001 From: javiergarciavera <76975121+javiergarciavera@users.noreply.github.com> Date: Tue, 31 Mar 2026 12:47:59 +0200 Subject: [PATCH 8/8] test: added new sentry attributes (#28138) ## **Description** Added new scenario attributes to Sentry: - Browserstack recording link - Github action link - Scenario team owner ## **Changelog** CHANGELOG entry: ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** ### **Before** ### **After** ## **Pre-merge author checklist** - [ ] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- > [!NOTE] > **Medium Risk** > Adds new external BrowserStack API calls and extra metadata to Sentry transaction payloads during test teardown, which could impact reporting or introduce intermittent failures if env/config changes. Core app logic is unaffected. > > **Overview** > E2E performance runs now publish richer Sentry transactions by **mirroring key scenario attributes** (project/provider/team/status/retry/build variant/device/file path) into both Sentry `tags` and each step `span.data` for easier filtering and correlation. > > The performance fixture now optionally fetches and attaches a **BrowserStack session recording URL** (derived from the test `sessionId` annotation and BrowserStack session details) and the Sentry publisher also includes **GitHub Actions run/job metadata** (from `GITHUB_*` env vars) in `extra` and span data. > > Tests were updated to cover the new payload fields and to manage the additional GitHub env vars during setup/teardown. > > Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 8b2546d841aa4c1cb483ae58264e865b133960cb. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot). --- .../performance/performance-fixture.ts | 48 ++++++++-- .../sentry/PerformanceSentryPublisher.test.ts | 71 +++++++++++++++ .../sentry/PerformanceSentryPublisher.ts | 88 ++++++++++++++++--- 3 files changed, 187 insertions(+), 20 deletions(-) diff --git a/tests/framework/fixtures/performance/performance-fixture.ts b/tests/framework/fixtures/performance/performance-fixture.ts index a8ab7f0ea09f..9568716f7fcb 100644 --- a/tests/framework/fixtures/performance/performance-fixture.ts +++ b/tests/framework/fixtures/performance/performance-fixture.ts @@ -4,6 +4,7 @@ import { type MetricsOutput, } from '../../../reporters/PerformanceTracker'; import { publishPerformanceScenarioToSentry } from '../../../reporters/providers/sentry/PerformanceSentryPublisher'; +import { BrowserStackAPI } from '../../services/providers/browserstack/BrowserStackAPI'; import { QualityGatesValidator, markQualityGateFailure, @@ -17,6 +18,36 @@ interface PerformanceFixtures { performanceTracker: PerformanceTracker; } +async function getBrowserStackRecordingUrl( + sessionId: string | null, + projectName: string, +): Promise { + if (!sessionId || !projectName.toLowerCase().includes('browserstack')) { + return null; + } + + try { + const api = new BrowserStackAPI(); + const sessionDetails = await api.getSessionDetails(sessionId); + if (!sessionDetails?.buildId) { + return null; + } + + return api.buildSessionURL(sessionDetails.buildId, sessionId); + } catch { + return null; + } +} + +function getSessionIdFromAnnotations( + annotations?: { type: string; description?: string }[], +): string | null { + return ( + annotations?.find((annotation) => annotation.type === 'sessionId') + ?.description ?? null + ); +} + // Create a custom test fixture that handles performance tracking and cleanup export const test = base.extend({ // eslint-disable-next-line no-empty-pattern @@ -76,13 +107,21 @@ export const test = base.extend({ ); } + const sessionId = getSessionIdFromAnnotations(testInfo.annotations); + if (metrics) { + const browserstackRecordingUrl = await getBrowserStackRecordingUrl( + sessionId, + testInfo.project?.name ?? 'unknown', + ); + try { const sentToSentry = await publishPerformanceScenarioToSentry({ metrics, testTitle: testInfo.title, projectName: testInfo.project?.name ?? 'unknown', testFilePath: testInfo.file, + browserstackRecordingUrl, tags: testTags, status: testInfo.status, retry: testInfo.retry, @@ -128,15 +167,6 @@ export const test = base.extend({ console.log('🔍 Looking for session ID...'); - let sessionId: string | null = null; - - if (testInfo?.annotations) { - sessionId = - testInfo.annotations.find( - (annotation) => annotation.type === 'sessionId', - )?.description ?? null; - } - if (sessionId) { // Store session data as a test attachment for the reporter to find // Include team info and tags in session data diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts index e25d10517f96..186348e77b50 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.test.ts @@ -66,6 +66,10 @@ describe('PerformanceSentryPublisher', () => { process.env.E2E_PERFORMANCE_SENTRY_SAMPLE_RATE; const originalSentryEnabled = process.env.E2E_PERFORMANCE_SENTRY_ENABLED; const originalBuildVariant = process.env.E2E_PERFORMANCE_BUILD_VARIANT; + const originalGithubServerUrl = process.env.GITHUB_SERVER_URL; + const originalGithubRepository = process.env.GITHUB_REPOSITORY; + const originalGithubRunId = process.env.GITHUB_RUN_ID; + const originalGithubJob = process.env.GITHUB_JOB; beforeEach(() => { jest.clearAllMocks(); @@ -73,6 +77,10 @@ describe('PerformanceSentryPublisher', () => { delete process.env.E2E_PERFORMANCE_SENTRY_SAMPLE_RATE; delete process.env.E2E_PERFORMANCE_SENTRY_ENABLED; delete process.env.E2E_PERFORMANCE_BUILD_VARIANT; + delete process.env.GITHUB_SERVER_URL; + delete process.env.GITHUB_REPOSITORY; + delete process.env.GITHUB_RUN_ID; + delete process.env.GITHUB_JOB; fetchMock = jest.spyOn(global, 'fetch'); }); @@ -101,6 +109,30 @@ describe('PerformanceSentryPublisher', () => { process.env.E2E_PERFORMANCE_BUILD_VARIANT = originalBuildVariant; } + if (originalGithubServerUrl === undefined) { + delete process.env.GITHUB_SERVER_URL; + } else { + process.env.GITHUB_SERVER_URL = originalGithubServerUrl; + } + + if (originalGithubRepository === undefined) { + delete process.env.GITHUB_REPOSITORY; + } else { + process.env.GITHUB_REPOSITORY = originalGithubRepository; + } + + if (originalGithubRunId === undefined) { + delete process.env.GITHUB_RUN_ID; + } else { + process.env.GITHUB_RUN_ID = originalGithubRunId; + } + + if (originalGithubJob === undefined) { + delete process.env.GITHUB_JOB; + } else { + process.env.GITHUB_JOB = originalGithubJob; + } + fetchMock.mockRestore(); }); @@ -124,6 +156,10 @@ describe('PerformanceSentryPublisher', () => { process.env.E2E_PERFORMANCE_SENTRY_DSN = 'https://publicKey@o123.ingest.sentry.io/4567'; process.env.E2E_PERFORMANCE_BUILD_VARIANT = 'exp'; + process.env.GITHUB_SERVER_URL = 'https://github.com'; + process.env.GITHUB_REPOSITORY = 'MetaMask/metamask-mobile'; + process.env.GITHUB_RUN_ID = '12345'; + process.env.GITHUB_JOB = 'e2e-performance-android'; fetchMock.mockResolvedValue({ ok: true, status: 200, @@ -134,6 +170,8 @@ describe('PerformanceSentryPublisher', () => { testTitle: 'Import wallet flow', projectName: 'browserstack-android', testFilePath: 'tests/performance/onboarding/import-wallet.spec.js', + browserstackRecordingUrl: + 'https://app-automate.browserstack.com/builds/build-123/sessions/sess-123', tags: ['@PerformanceOnboarding', '@PerformanceLaunch'], status: 'passed', retry: 0, @@ -168,7 +206,40 @@ describe('PerformanceSentryPublisher', () => { expect(payload.measurements.scenario_total_time_ms.value).toBe(1300); expect(payload.tags.project_name).toBe('browserstack-android'); expect(payload.tags.build_variant).toBe('exp'); + expect(payload.tags.test_team).toBe('qa-automation'); expect(payload.extra.timer_steps).toHaveLength(2); + expect(payload.extra.recording_url).toBe( + 'https://app-automate.browserstack.com/builds/build-123/sessions/sess-123', + ); + expect(payload.extra.github_job_url).toBe( + 'https://github.com/MetaMask/metamask-mobile/actions/runs/12345', + ); + expect(payload.extra.github_job_name).toBe('e2e-performance-android'); + expect(payload.spans).toHaveLength(2); + expect(payload.spans[0].op).toBe('e2e.performance.step'); + expect(payload.spans[0].data.project_name).toBe('browserstack-android'); + expect(payload.spans[0].data.test_team).toBe('qa-automation'); + expect(payload.spans[0].data.provider).toBe('browserstack'); + expect(payload.spans[0].data.team_id).toBe('qa-automation'); + expect(payload.spans[0].data.team_name).toBe('QA Automation'); + expect(payload.spans[0].data.test_status).toBe('passed'); + expect(payload.spans[0].data.retry).toBe(0); + expect(payload.spans[0].data.worker_index).toBe(3); + expect(payload.spans[0].data.build_variant).toBe('exp'); + expect(payload.spans[0].data.device_name).toBe('Samsung Galaxy S23 Ultra'); + expect(payload.spans[0].data.device_os_version).toBe('13.0'); + expect(payload.spans[0].data.test_file_path).toBe( + 'tests/performance/onboarding/import-wallet.spec.js', + ); + expect(payload.spans[0].data.recording_url).toBe( + 'https://app-automate.browserstack.com/builds/build-123/sessions/sess-123', + ); + expect(payload.spans[0].data.github_job_url).toBe( + 'https://github.com/MetaMask/metamask-mobile/actions/runs/12345', + ); + expect(payload.spans[0].data.github_job_name).toBe( + 'e2e-performance-android', + ); }); it('protects reserved aggregate keys from timer-key collisions', async () => { diff --git a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts index c751df9bf35a..ac25a7b3663f 100644 --- a/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts +++ b/tests/reporters/providers/sentry/PerformanceSentryPublisher.ts @@ -13,6 +13,10 @@ const ENV_SENTRY_SAMPLE_RATE = 'E2E_PERFORMANCE_SENTRY_SAMPLE_RATE'; const ENV_SENTRY_ENVIRONMENT = 'E2E_PERFORMANCE_SENTRY_ENVIRONMENT'; const ENV_SENTRY_RELEASE = 'E2E_PERFORMANCE_SENTRY_RELEASE'; const ENV_SENTRY_BUILD_VARIANT = 'E2E_PERFORMANCE_BUILD_VARIANT'; +const ENV_GITHUB_SERVER_URL = 'GITHUB_SERVER_URL'; +const ENV_GITHUB_REPOSITORY = 'GITHUB_REPOSITORY'; +const ENV_GITHUB_RUN_ID = 'GITHUB_RUN_ID'; +const ENV_GITHUB_JOB = 'GITHUB_JOB'; const MAX_MEASUREMENT_KEY_LENGTH = 64; const RESERVED_MEASUREMENT_KEYS = [ 'scenario_total_time_ms', @@ -24,6 +28,7 @@ interface PublishPerformanceScenarioOptions { testTitle: string; projectName: string; testFilePath?: string; + browserstackRecordingUrl?: string | null; tags: string[]; status?: string; retry?: number; @@ -53,6 +58,24 @@ interface SentryMeasurement { unit: 'millisecond'; } +interface MirroredScenarioAttributes { + project_name: string; + test_team: string; + provider: string; + team_id: string; + team_name: string; + test_status: string; + retry: number; + worker_index: number; + build_variant: 'rc' | 'exp' | 'unknown'; + device_name: string; + device_os_version: string; + test_file_path: string; + recording_url: string | null; + github_job_url: string | null; + github_job_name: string | null; +} + function getEnvValue(key: string): string | undefined { return Reflect.get(process.env, key) as string | undefined; } @@ -178,6 +201,17 @@ function parseSampleRate(rawSampleRate: string | undefined): number | null { return sampleRate; } +function getGithubJobUrl(): string | null { + const serverUrl = getEnvValue(ENV_GITHUB_SERVER_URL); + const repository = getEnvValue(ENV_GITHUB_REPOSITORY); + const runId = getEnvValue(ENV_GITHUB_RUN_ID); + if (!serverUrl || !repository || !runId) { + return null; + } + + return `${serverUrl}/${repository}/actions/runs/${runId}`; +} + export async function publishPerformanceScenarioToSentry( options: PublishPerformanceScenarioOptions, ): Promise { @@ -255,6 +289,35 @@ export async function publishPerformanceScenarioToSentry( }; } + const provider = options.metrics.device.provider || 'unknown'; + const teamId = options.metrics.team?.teamId || 'unknown'; + const teamName = options.metrics.team?.teamName || 'unknown'; + const testStatus = options.status || 'unknown'; + const retry = options.retry ?? 0; + const workerIndex = options.workerIndex ?? 0; + const buildVariant = normalizeBuildVariant( + getEnvValue(ENV_SENTRY_BUILD_VARIANT), + ); + const testFilePath = options.testFilePath || ''; + + const mirroredScenarioAttributes: MirroredScenarioAttributes = { + project_name: options.projectName, + test_team: teamId, + provider, + team_id: teamId, + team_name: teamName, + test_status: testStatus, + retry, + worker_index: workerIndex, + build_variant: buildVariant, + device_name: options.metrics.device.name, + device_os_version: options.metrics.device.osVersion, + test_file_path: testFilePath, + recording_url: options.browserstackRecordingUrl ?? null, + github_job_url: getGithubJobUrl(), + github_job_name: getEnvValue(ENV_GITHUB_JOB) ?? null, + }; + let cursor = startTimestamp; const spans = timerMeasurements.map((timerMeasurement) => { const spanStart = cursor; @@ -276,6 +339,7 @@ export async function publishPerformanceScenarioToSentry( base_threshold_ms: timerMeasurement.baseThreshold, exceeded_ms: timerMeasurement.exceeded, percent_over: timerMeasurement.percentOver, + ...mirroredScenarioAttributes, }, }; }); @@ -310,21 +374,23 @@ export async function publishPerformanceScenarioToSentry( }, tags: { source: 'appwright-e2e-performance', - project_name: options.projectName, - provider: options.metrics.device.provider || 'unknown', - team_id: options.metrics.team?.teamId || 'unknown', - team_name: options.metrics.team?.teamName || 'unknown', - test_status: options.status || 'unknown', - retry: String(options.retry ?? 0), - worker_index: String(options.workerIndex ?? 0), - build_variant: normalizeBuildVariant( - getEnvValue(ENV_SENTRY_BUILD_VARIANT), - ), + project_name: mirroredScenarioAttributes.project_name, + provider: mirroredScenarioAttributes.provider, + team_id: mirroredScenarioAttributes.team_id, + team_name: mirroredScenarioAttributes.team_name, + test_team: mirroredScenarioAttributes.test_team, + test_status: mirroredScenarioAttributes.test_status, + retry: String(mirroredScenarioAttributes.retry), + worker_index: String(mirroredScenarioAttributes.worker_index), + build_variant: mirroredScenarioAttributes.build_variant, }, measurements, spans, extra: { - test_file_path: options.testFilePath || '', + test_file_path: mirroredScenarioAttributes.test_file_path, + recording_url: mirroredScenarioAttributes.recording_url, + github_job_url: mirroredScenarioAttributes.github_job_url, + github_job_name: mirroredScenarioAttributes.github_job_name, test_tags: options.tags, threshold_margin_percent: options.metrics.thresholdMarginPercent, has_thresholds: options.metrics.hasThresholds,