diff --git a/.android.env.example b/.android.env.example index d630c9b4f08..c9805d0a180 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/CODEOWNERS b/.github/CODEOWNERS index b411351a16f..3f3edeb14c6 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. diff --git a/.github/workflows/push-eas-update.yml b/.github/workflows/push-eas-update.yml index 426db1b1615..5eac948db24 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/.github/workflows/runway-android-production-workflow.yml b/.github/workflows/runway-android-production-workflow.yml new file mode 100644 index 00000000000..fdac5745de2 --- /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 00000000000..04fabffcf43 --- /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 00000000000..6119c040b7a --- /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 00000000000..b913cc4d588 --- /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 00000000000..d0107b84508 --- /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 d859f53a641..bf427bb8306 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 ec32abaf45e..00000000000 --- 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 diff --git a/.ios.env.example b/.ios.env.example index bd49b067660..dd2d4ddd083 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 83b4d97b81b..13cec147ce2 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 00000000000..d7fa9200cd2 --- /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 aa23b78eea7..fb012bb9844 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 845d99f6471..c8727563234 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 01a8c08d81d..c700511130b 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/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx b/app/components/UI/Bridge/components/BridgeTrendingTokensSection/BridgeTrendingTokensSection.test.tsx index 1e6cbd56df7..2b06e6f22aa 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 fcc21b09e8e..cc8a14b991f 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/SecurityTrust/Views/SecurityTrustScreen.tsx b/app/components/UI/SecurityTrust/Views/SecurityTrustScreen.tsx index 5fe9d0a2f15..d94fa2172af 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 4206c372f7d..c9cf0d7ca13 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 70a32d6d2f1..637177e0893 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 5efd3e2a970..e67451cdc77 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/constants/constants.ts b/app/components/UI/TokenDetails/constants/constants.ts index 0c7a8739f3b..711f304c5e1 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/TokenDetails/hooks/useTokenActions.test.ts b/app/components/UI/TokenDetails/hooks/useTokenActions.test.ts index 2c0182cfb03..d4f2dfddeac 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 08af6153941..756d3c3dada 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, }; diff --git a/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx b/app/components/UI/Trending/components/TrendingTokenRowItem/TrendingTokenRowItem.test.tsx index 1d073d269f0..2f9a089faf3 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 c6aead4e767..a3ea2e3e344 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), 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 e66f2ad5f89..ed4af15eec1 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 2c1f00738d3..c50d75edb56 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 b8555735c99..248e714f5e0 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 01fc456b582..ebf5ddf437e 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 d7ed0ecc630..0842218d2dd 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 57b141602c9..5ad9b1b9b6e 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 716f0029eb7..b1035071e15 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 7bb10c34a9f..a0ddb3cf043 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 10da6d7d830..00000000000 --- 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 4003b878be0..00000000000 --- 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; -} diff --git a/app/core/Braze/index.test.ts b/app/core/Braze/index.test.ts new file mode 100644 index 00000000000..4a9cb904f6d --- /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 00000000000..b3c9364d35d --- /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 00000000000..726de00d664 --- /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 00000000000..c90ec849622 --- /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 1e945696a47..ed1a1621085 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 cae37d9f686..fb039a11383 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 e7086892d9f..6cfa8b19cfc 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 ba89a5c7d5c..7edd2821e54 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 af9811a4ab5..e1829ab99c0 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 4988e93c41c..c236763636e 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 275874ff061..9ed87e28cb2 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 e7bd3c5bf78..0543c26d779 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 2b49cf8a0d4..75aa83c702d 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 dcfa2445fe1..33f62ca1990 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 9e3b56428a4..c24a521871d 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", @@ -313,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/scripts/build.sh b/scripts/build.sh index fbbe944d78b..cc7fc90bc89 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 882e1d467f8..0bfd6356ed8 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/tests/framework/fixtures/performance/performance-fixture.ts b/tests/framework/fixtures/performance/performance-fixture.ts index a8ab7f0ea09..9568716f7fc 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 e25d10517f9..186348e77b5 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 c751df9bf35..ac25a7b3663 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, diff --git a/yarn.lock b/yarn.lock index 6e5d302e800..c277962078b 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" @@ -10216,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 @@ -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" @@ -35664,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"