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"