From 786a22ab08e1333ec719959cf99d9c308af220a6 Mon Sep 17 00:00:00 2001
From: tommasini <46944231+tommasini@users.noreply.github.com>
Date: Mon, 25 May 2026 12:53:39 +0100
Subject: [PATCH 1/6] chore: bump qs 6.15.2 (#30586)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Bump qs 15.2
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Dependency-only patch with no app code changes; `qs` is a transitive
query-string parser, so blast radius is limited to how dependents
serialize/parse URLs.
>
> **Overview**
> Upgrades the **`qs`** query-string library from **6.14.1** to
**6.15.2** across Yarn resolutions, direct `package.json` dependencies,
and **`yarn.lock`**. No application source files change—only dependency
pins and lockfile metadata (version, resolution checksum).
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
ea4207acd226de2d47a60588c8af1c2a1bfc9a5c. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
package.json | 2 +-
yarn.lock | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index 7414345e2f33..cbbb79af358b 100644
--- a/package.json
+++ b/package.json
@@ -463,7 +463,7 @@
"prop-types": "15.7.2",
"pump": "3.0.0",
"punycode": "^2.1.1",
- "qs": "6.14.1",
+ "qs": "6.15.2",
"query-string": "^6.12.1",
"randomfill": "^1.0.4",
"react": "19.1.0",
diff --git a/yarn.lock b/yarn.lock
index 65d737999121..93b6c001a531 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -35547,7 +35547,7 @@ __metadata:
prop-types: "npm:15.7.2"
pump: "npm:3.0.0"
punycode: "npm:^2.1.1"
- qs: "npm:6.14.1"
+ qs: "npm:6.15.2"
query-string: "npm:^6.12.1"
randomfill: "npm:^1.0.4"
react: "npm:19.1.0"
From 4feb508488be82890a2ac5174416c00753a0fafa Mon Sep 17 00:00:00 2001
From: Andre Pimenta
Date: Mon, 25 May 2026 14:22:53 +0100
Subject: [PATCH 2/6] fix(android): exclude x86_64 ABI from production AAB to
resolve Play 16 KB warning (#30590)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Google Play flagged two prebuilt `.so` files in our production AAB as
non-compliant with Android's 16 KB page-size requirement, both in the
`x86_64` ABI:
- `base/lib/x86_64/libconceal.so` — from `react-native-keychain` via
`com.facebook.conceal:1.1.3` (last released in 2016, archived upstream).
- `base/lib/x86_64/libsecp256k1.so` — from `react-native-fast-crypto`,
shipped as an `IMPORTED` prebuilt in its CMake config (not rebuilt from
source).
Neither library can be realistically rebuilt with 16 KB alignment:
- Facebook Conceal has been unmaintained since 2018; there is no
source-of-truth fork with 16 KB alignment.
- `react-native-fast-crypto` consumes `libsecp256k1.so` as a prebuilt
binary; the existing yarn patch already adds
`-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON` for the bridge code we
compile, but it does not regenerate the imported `libsecp256k1.so`
files.
x86_64 is only ever used by Chromebook ARC++/ARCVM and emulators — no
real phone ships with an x86_64 CPU. Dropping the x86_64 ABI from
production AABs silences the Play warning without affecting users.
### What this PR does
Two changes that need to land together:
1. **`android/gradle.properties.release`** — remove `x86_64` from
`reactNativeArchitectures` so React Native's own native libs (Hermes, RN
core) aren't built for that ABI in the production AAB.
2. **`android/app/build.gradle`** — add
`ndk.abiFilters(*reactNativeArchitectures())` inside `defaultConfig`.
Without this, AGP packages every ABI shipped in dependency AARs
regardless of `reactNativeArchitectures`, which is what put the x86_64
`libconceal.so` and `libsecp256k1.so` into the AAB in the first place.
A small refactor of the `reactNativeArchitectures()` helper to return a
`List` (via `.toList()`) makes the Groovy spread
`*reactNativeArchitectures()` into `abiFilters(String...)` robust across
AGP/Groovy versions.
### What this PR does NOT change
- `android/gradle.properties` (default) — local dev still gets x86_64
emulator support.
- `android/gradle.properties.github` (CI E2E, `x86_64` only) — the new
`abiFilters` resolves to `["x86_64"]` in that context, so emulator tests
continue to work.
- `android/gradle.properties.github.dual-versions`
(`armeabi-v7a,arm64-v8a`) — already excludes x86_64.
- `scripts/build.sh` `-PreactNativeArchitectures=...` CLI overrides —
still respected by the helper.
### Follow-up work (out of scope)
The same `.so` files still ship for `arm64-v8a` with 4 KB alignment.
If/when Play extends the warning to arm64, or enforces the runtime 16 KB
device requirement more aggressively on Android 15+ devices in 16 KB
mode, these will need structural fixes:
- Replace `react-native-fast-crypto` with
`react-native-quick-crypto@1.x` (we only consume `scrypt`).
- Drop the Conceal dependency from `react-native-keychain` (yarn patch
or upgrade to v10) — safe because `minSdk = 24` means the Conceal cipher
path is never selected at runtime.
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: Google Play 16 KB page-size warning for
`base/lib/x86_64/libconceal.so` and `base/lib/x86_64/libsecp256k1.so`.
## **Manual testing steps**
```gherkin
Feature: Production AAB no longer contains x86_64 ABI
Scenario: A production release build is generated
Given a clean checkout of this branch
When the production AAB is built via the standard release workflow
(the same one that does `cp android/gradle.properties.release android/gradle.properties`
before `./gradlew bundleProdRelease`)
Then `unzip -l app-prod-release.aab | grep '/lib/'` shows only
`lib/arm64-v8a/`, `lib/armeabi-v7a/`, and `lib/x86/` entries
And no `lib/x86_64/` entries appear in the output
And no `libconceal.so` or `libsecp256k1.so` files appear under any
`lib/x86_64/` path
Scenario: Local dev on x86_64 emulator still works
Given a developer running `yarn android` on an Intel Mac with an x86_64 emulator
When the debug variant is built using the default `android/gradle.properties`
Then x86_64 native libs are still produced and the app installs and runs
Scenario: CI E2E on x86_64 emulator still works
Given CI overlays `android/gradle.properties.github` (reactNativeArchitectures=x86_64)
When `./gradlew assembleProdDebug` runs
Then the resulting APK contains a `lib/x86_64/` directory and Detox tests pass
```
## **Screenshots/Recordings**
### **Before**
Play Console flagged:
- `base/lib/x86_64/libconceal.so`
- `base/lib/x86_64/libsecp256k1.so`
### **After**
Production AAB contains no `lib/x86_64/` directory — both warnings
cleared.
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable — N/A (build-config change;
verification is via the `unzip -l` check on the produced AAB)
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable — N/A
- [ ] I've applied the right labels on the PR — `team-mobile-platform`
#### Performance checks (if applicable)
- [x] I've tested on Android — verified locally that the abiFilters
change does not break the `gradle.properties.github` (x86_64-only) path
used by CI E2E.
- [ ] I've tested with a power user scenario — N/A (no JS behavior
change)
- [ ] I've instrumented key operations with Sentry traces — N/A
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
Made with [Cursor](https://cursor.com)
---
> [!NOTE]
> **Low Risk**
> Build-time ABI and packaging configuration only; no runtime app logic,
auth, or data-path changes, with emulator/CI paths still driven by their
gradle overlays.
>
> **Overview**
> Production Play Store builds stop shipping **x86_64** native libraries
so Google Play’s **16 KB page-size** warnings go away for prebuilt
`libconceal.so` and `libsecp256k1.so` that only appeared under
`lib/x86_64/`.
>
> **`android/gradle.properties.release`** drops `x86_64` from
`reactNativeArchitectures` (now `armeabi-v7a`, `arm64-v8a`, `x86`).
**`android/app/build.gradle`** adds
`defaultConfig.ndk.abiFilters(*reactNativeArchitectures())` so AGP does
not still package every ABI from third-party AARs when RN’s arch list is
narrower. The `reactNativeArchitectures()` helper now returns a Groovy
`List` via `.toList()` so the spread into `abiFilters` is reliable.
>
> Default and CI gradle property files are unchanged in this diff: local
debug can still target x86_64 emulators, and CI E2E overlays that still
set `x86_64` only continue to filter to that ABI.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
40dd023f50a3ea330c882cc0a76ee8eb82563676. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
Co-authored-by: Cursor
---
android/app/build.gradle | 14 +++++++++++++-
android/gradle.properties.release | 9 +++++++--
2 files changed, 20 insertions(+), 3 deletions(-)
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 1f808b247503..4647d5eeced6 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -163,7 +163,10 @@ def jscFlavor = 'org.webkit:android-jsc:+'
*/
def reactNativeArchitectures() {
def value = project.getProperties().get("reactNativeArchitectures")
- return value ? value.split(",") : ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
+ if (value) {
+ return value.split(",").toList()
+ }
+ return ["armeabi-v7a", "x86", "x86_64", "arm64-v8a"]
}
@@ -191,6 +194,15 @@ android {
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 ?: ''}"
+ ndk {
+ // Restrict packaged .so files (including those pulled from third-party AARs
+ // like Facebook Conceal and react-native-fast-crypto) to the ABIs listed in
+ // reactNativeArchitectures. Without this, AGP packages every ABI shipped in
+ // dependency AARs regardless, which is what put x86_64 libconceal.so and
+ // libsecp256k1.so into the AAB and triggered Play's 16 KB alignment warning.
+ abiFilters(*reactNativeArchitectures())
+ }
+
// Explicitly specify supported languages for the app, ensuring the app locales are valid when uploading to the Play Store
resourceConfigurations += ['en', 'de', 'el', 'es', 'fr', 'hi', 'id', 'ja', 'ko', 'pt', 'ru', 'tl', 'tr', 'vi', 'zh']
}
diff --git a/android/gradle.properties.release b/android/gradle.properties.release
index 635d2e01646d..c83796ffed7b 100644
--- a/android/gradle.properties.release
+++ b/android/gradle.properties.release
@@ -31,8 +31,13 @@ android.enableJetifier=true
# Enable AAPT2 PNG crunching
android.enablePngCrunchInReleaseBuilds=true
-# ALL architectures for Play Store distribution (differs from E2E which uses x86_64 only)
-reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
+# Play Store distribution architectures. x86_64 is excluded because the
+# AAR-bundled libconceal.so (react-native-keychain) and libsecp256k1.so
+# (react-native-fast-crypto) ship 4 KB-aligned x86_64 binaries that fail
+# Play's 16 KB page-size check. No real phone uses x86_64 (Chromebooks/
+# emulators only), so dropping it silences the warning without affecting
+# users. Enforced at packaging time via ndk.abiFilters in app/build.gradle.
+reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86
# New Architecture + Hermes
newArchEnabled=true
From 59e4e974d0eca968e88d2832a6683184e7b5d43f Mon Sep 17 00:00:00 2001
From: abretonc7s <107169956+abretonc7s@users.noreply.github.com>
Date: Mon, 25 May 2026 21:51:39 +0800
Subject: [PATCH 3/6] feat(perps): closing order type bottom sheet with a tap
outside bottom sheet closes order screen (#30561)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Fixes a bug where closing the order type bottom sheet (by tapping
outside, pressing the close button, or selecting an option) would also
close the entire order screen.
**Root cause**: `PerpsOrderTypeBottomSheet` passed
`shouldNavigateBack={!externalSheetRef}` to the `BottomSheet` component.
When embedded in `PerpsOrderView` (no external sheet ref), this
evaluated to `true`, causing the BottomSheet infrastructure to call
`navigation.goBack()` on dismiss — popping the order screen from the
navigation stack. The parent's `onClose` callback then fired
redundantly.
**Fix**: Set `shouldNavigateBack={false}` unconditionally and always
pass the `onClose` prop through to BottomSheet, so the parent controls
what happens on dismiss.
## **Changelog**
CHANGELOG entry: Fixed order type bottom sheet dismissal incorrectly
closing the order screen
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/TAT-3113
## **Manual testing steps**
```gherkin
Feature: Order type bottom sheet dismissal
Scenario: User selects an order type from the bottom sheet
Given the user is on the perps order screen with the order type bottom sheet open
When user taps on "Market" or "Limit" order type
Then the bottom sheet closes
And the order screen remains visible and functional
Scenario: User reopens the order type bottom sheet after dismissal
Given the user just dismissed the order type bottom sheet
When user taps the order type button in the order header
Then the order type bottom sheet opens again
And the user can select a different order type
```
## **Screenshots/Recordings**
### **Before**
Order type sheet dismissal (tap outside or select option) closes the
entire order screen — user is navigated back to market details.
https://github.com/user-attachments/assets/a8a36f0a-6be5-418b-8c95-550bd4dc8056
### **After**
Order type sheet dismissal keeps the order screen open. User can reopen
the sheet and select a different order type.
https://github.com/user-attachments/assets/d2d18a62-42fa-433b-8ba5-812e01d19f1d
## **Validation Recipe**
recipe.json (18 steps — order type sheet
open/dismiss/reopen cycle)
```json
{
"schema_version": 1,
"title": "TAT-3113: Order type bottom sheet dismissal does not close order screen",
"jira": "TAT-3113",
"description": "Verifies that dismissing the order type bottom sheet (by selecting an option) keeps the user on the order screen instead of navigating back.",
"acceptance_criteria": [
"AC1: Selecting an order type from the bottom sheet closes the sheet but does NOT close the order screen",
"AC2: After dismissal, the order form remains visible and functional (can reopen the sheet)"
],
"validate": {
"workflow": {
"pre_conditions": ["wallet.unlocked", "perps.ready_to_trade"],
"entry": "nav-to-details",
"nodes": {
"nav-to-details": {
"action": "navigate",
"target": "PerpsMarketDetails",
"params": { "market": { "symbol": "BTC" } },
"next": "wait-details-loaded"
},
"wait-details-loaded": {
"action": "wait_for",
"test_id": "perps-market-details-long-button",
"timeout_ms": 8000,
"next": "open-order"
},
"open-order": {
"action": "press",
"test_id": "perps-market-details-long-button",
"next": "wait-order-form"
},
"wait-order-form": {
"action": "wait_for",
"test_id": "perps-order-header-order-type-button",
"timeout_ms": 5000,
"next": "open-order-type-sheet"
},
"open-order-type-sheet": {
"action": "press",
"test_id": "perps-order-header-order-type-button",
"next": "wait-sheet"
},
"wait-sheet": {
"action": "wait_for",
"test_id": "perps-order-type-market",
"timeout_ms": 3000,
"next": "screenshot-sheet-open"
},
"screenshot-sheet-open": {
"action": "screenshot",
"filename": "evidence-order-type-sheet-open.png",
"note": "Order type bottom sheet is open showing Market and Limit options",
"next": "dismiss-select-market"
},
"dismiss-select-market": {
"action": "press",
"test_id": "perps-order-type-market",
"next": "wait-dismiss"
},
"wait-dismiss": {
"action": "wait",
"duration_ms": 1000,
"next": "verify-still-on-order"
},
"verify-still-on-order": {
"action": "eval_sync",
"expression": "(function(){ var r = globalThis.__AGENTIC__.getRoute(); return JSON.stringify({ route: r.name }); })()",
"assert": { "operator": "eq", "field": "route", "value": "RedesignedConfirmations" },
"next": "verify-order-form-visible"
},
"verify-order-form-visible": {
"action": "eval_sync",
"expression": "(function(){ var el = globalThis.__AGENTIC__.findFiberByTestId('perps-order-view-place-order-button'); return JSON.stringify({ visible: !!el }); })()",
"assert": { "operator": "eq", "field": "visible", "value": true },
"next": "screenshot-after-dismiss"
},
"screenshot-after-dismiss": {
"action": "screenshot",
"filename": "evidence-order-still-visible.png",
"note": "Order form still visible after selecting order type — order screen was NOT closed",
"next": "reopen-sheet"
},
"reopen-sheet": {
"action": "press",
"test_id": "perps-order-header-order-type-button",
"next": "wait-reopen"
},
"wait-reopen": {
"action": "wait_for",
"test_id": "perps-order-type-limit",
"timeout_ms": 3000,
"next": "select-limit"
},
"select-limit": {
"action": "press",
"test_id": "perps-order-type-limit",
"next": "wait-dismiss-2"
},
"wait-dismiss-2": {
"action": "wait",
"duration_ms": 1000,
"next": "verify-still-on-order-2"
},
"verify-still-on-order-2": {
"action": "eval_sync",
"expression": "(function(){ var r = globalThis.__AGENTIC__.getRoute(); return JSON.stringify({ route: r.name }); })()",
"assert": { "operator": "eq", "field": "route", "value": "RedesignedConfirmations" },
"next": "screenshot-final"
},
"screenshot-final": {
"action": "screenshot",
"filename": "evidence-limit-selected-still-on-order.png",
"note": "After selecting Limit order type, order form still visible with limit type active",
"next": "done"
},
"done": {
"action": "end",
"status": "pass"
}
}
}
}
}
```
## **Validation Logs**
Command:
```bash
IOS_SIMULATOR=mm-3 bash scripts/perps/agentic/validate-recipe.sh .task/feat/tat-3113-0521-165010/artifacts/ --skip-manual
```
Full output (18/18 passed)
```
Running recipe: TAT-3113: Order type bottom sheet dismissal does not close order screen
Pre-conditions: wallet.unlocked, perps.ready_to_trade
Workflow nodes: 19
Pre-conditions: PASS
[nav-to-details] navigate to PerpsMarketDetails — PASS
[wait-details-loaded] wait for perps-market-details-long-button — PASS
[open-order] press perps-market-details-long-button — PASS
[wait-order-form] wait for perps-order-header-order-type-button — PASS
[open-order-type-sheet] press perps-order-header-order-type-button — PASS
[wait-sheet] wait for perps-order-type-market — PASS
[screenshot-sheet-open] screenshot evidence-order-type-sheet-open.png — PASS
[dismiss-select-market] press perps-order-type-market — PASS
[wait-dismiss] wait 1000ms — PASS
[verify-still-on-order] eval_sync route=RedesignedConfirmations — PASS
[verify-order-form-visible] eval_sync visible=true — PASS
[screenshot-after-dismiss] screenshot evidence-order-still-visible.png — PASS
[reopen-sheet] press perps-order-header-order-type-button — PASS
[wait-reopen] wait for perps-order-type-limit — PASS
[select-limit] press perps-order-type-limit — PASS
[wait-dismiss-2] wait 1000ms — PASS
[verify-still-on-order-2] eval_sync route=RedesignedConfirmations — PASS
[screenshot-final] screenshot evidence-limit-selected-still-on-order.png — PASS
Results: 18/18 passed
Recipe: PASS
```
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Single-component BottomSheet prop change for a nested sheet; no auth,
payments, or shared infrastructure changes.
>
> **Overview**
> Fixes perps order flow navigation so dismissing the **order type**
bottom sheet no longer pops the whole order screen.
>
> `PerpsOrderTypeBottomSheet` now always passes
`shouldNavigateBack={false}` to `BottomSheet` and always wires
`onClose`, instead of enabling `navigation.goBack()` when no external
`sheetRef` is provided. Dismissal (tap outside, close, or picking
Market/Limit) only runs the parent `onClose` handler and leaves the user
on the order form.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
77e3642c7edd854661845462250cdd3373d1922b. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx | 6 +-----
1 file changed, 1 insertion(+), 5 deletions(-)
diff --git a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
index 1813e4de2ee6..a4f39182ff37 100644
--- a/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
+++ b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx
@@ -91,11 +91,7 @@ const PerpsOrderTypeBottomSheet: React.FC = ({
if (!isVisible) return null;
return (
-
+
{strings('perps.order.type.title')}
From ac04e1f9a6d5d253b78310c68b92d28add69ed14 Mon Sep 17 00:00:00 2001
From: Nico MASSART
Date: Mon, 25 May 2026 15:52:09 +0200
Subject: [PATCH 4/6] feat: add agent-device tooling for AI simulator control
(#30302)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds [agent-device](https://github.com/callstackincubator/agent-device)
as a dev dependency, exposing it as a CLI for AI agents to control
iOS/Android simulators during development.
Device control (opening the app, navigating screens, taking snapshots,
interacting with UI elements, capturing visual evidence) runs through:
```bash
yarn agent-device --json
```
The package is installed locally so no global install is required.
Version is pinned to an exact version (`0.14.8`, no semver range) as
recommended by the security team — combined with Yarn's lockfile
checksum, this prevents undetected same-tag re-deployments.
Ideally to be used with the [simulator-control
skill](https://github.com/Consensys/skills/pull/11)
## **Changelog**
CHANGELOG entry: null
## **Related issues**
Fixes: https://consensyssoftware.atlassian.net/browse/MCWP-450
## **Manual testing steps**
```gherkin
Feature: agent-device CLI availability
Background:
Given the branch is checked out and `yarn install` has been run
And the simulator-control skill is installed via `yarn skills --domain testing`
And an iOS simulator is booted
And Metro is running via `yarn watch:clean`
Scenario: AI agent controls the iOS simulator via CLI
Given the Cursor IDE is open on this project
When the user sends the following prompt to the Cursor agent:
"""
Open the MetaMask app on the iOS simulator and take a screenshot of the home screen
"""
And the agent runs `yarn agent-device devices --platform ios` to list booted simulators
And the agent runs `yarn agent-device open io.metamask.MetaMask --platform ios`
And the MetaMask app opens on the simulator
And the agent runs `yarn agent-device screenshot` and returns the image
```
## **Screenshots/Recordings**
### **Before**
N/A
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [x] I've tested on Android
- [x] I've tested with a power user scenario
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Low risk: adds a pinned dev-only CLI dependency plus lockfile updates
and documentation, with no runtime/app logic changes; main concern is
install friction due to `agent-device`'s Node engine declaration.
>
> **Overview**
> Adds the `agent-device` package (pinned to `0.14.8`) as a dev
dependency to provide a local `yarn agent-device` CLI for controlling
iOS/Android simulators.
>
> Updates `yarn.lock` for the new dependency and related transitive
bumps (notably `fast-xml-parser`/`fast-xml-builder`), adds
`agent-device` to `.depcheckrc.yml` ignores since it’s CLI-only, and
documents usage in `docs/readme/agent-device.md` including the Node
engine note.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
7c9637f5fc15c1abffe006d8e4bc543385a396dc. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.depcheckrc.yml | 2 ++
docs/readme/agent-device.md | 21 +++++++++++
package.json | 1 +
yarn.lock | 69 ++++++++++++++++++++++++++-----------
4 files changed, 73 insertions(+), 20 deletions(-)
create mode 100644 docs/readme/agent-device.md
diff --git a/.depcheckrc.yml b/.depcheckrc.yml
index 7e2004fa4c6e..15be8d3d86dd 100644
--- a/.depcheckrc.yml
+++ b/.depcheckrc.yml
@@ -40,6 +40,8 @@ ignores:
- 'esbuild-register'
# tsx runs all scripts/tooling/*.ts files directly (CLI wrapper, MCP server, yarn pre/post hooks, report CLI)
- 'tsx'
+ # agent-device is used as a CLI (`yarn agent-device`), not imported
+ - 'agent-device'
# xml2js is used in .github/scripts/ for E2E test report processing
- 'xml2js'
# jest-junit is used as a Jest reporter in tests/jest.e2e.detox.config.js
diff --git a/docs/readme/agent-device.md b/docs/readme/agent-device.md
new file mode 100644
index 000000000000..a83c29b12940
--- /dev/null
+++ b/docs/readme/agent-device.md
@@ -0,0 +1,21 @@
+# agent-device
+
+> **Node version note:** `agent-device` declares `engines.node >= 22.19`. The project currently pins Node 20.18.0 but an upgrade to Node 22 is planned, at which point this requirement will be fully satisfied. In the meantime it works correctly on Node 20 since Yarn does not enforce `engines` by default.
+
+All device control (open, screenshot, tap, scroll, type, …) runs through:
+
+`agent-device` is installed as a local dependency and used exclusively as a CLI.
+
+```bash
+yarn agent-device --json
+```
+
+Run `yarn agent-device --help` for the full command reference.
+
+## Skill (recommended)
+
+Installing the `simulator-control` skill from [Consensys/skills](https://github.com/Consensys/skills) provides MetaMask Mobile–specific guidance (app identifiers, deep links, prerequisites).
+
+```bash
+yarn skills --domain testing
+```
diff --git a/package.json b/package.json
index cbbb79af358b..d2cc9fc5559c 100644
--- a/package.json
+++ b/package.json
@@ -623,6 +623,7 @@
"@walletconnect/types": "^2.23.0",
"@wdio/protocols": "^9.27.0",
"@welldone-software/why-did-you-render": "^8.0.1",
+ "agent-device": "0.14.8",
"appium": "^2.5.4",
"appium-adb": "^9.11.4",
"appium-chromium-driver": "^2.0.2",
diff --git a/yarn.lock b/yarn.lock
index 93b6c001a531..4cd553c1bc7f 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -10701,6 +10701,13 @@ __metadata:
languageName: node
linkType: hard
+"@nodable/entities@npm:^2.1.0":
+ version: 2.1.0
+ resolution: "@nodable/entities@npm:2.1.0"
+ checksum: 10/355c55e82aebe45d4b962d16530951df51e19e3e63a27ea61ad3260c0807064619b270b9c83db10e8394f42760abd5b7f7c5b5117678c4246ce8364a4aafc637
+ languageName: node
+ linkType: hard
+
"@nodelib/fs.scandir@npm:2.1.5":
version: 2.1.5
resolution: "@nodelib/fs.scandir@npm:2.1.5"
@@ -21348,6 +21355,18 @@ __metadata:
languageName: node
linkType: hard
+"agent-device@npm:0.14.8":
+ version: 0.14.8
+ resolution: "agent-device@npm:0.14.8"
+ dependencies:
+ fast-xml-parser: "npm:^5.7.2"
+ pngjs: "npm:^7.0.0"
+ bin:
+ agent-device: bin/agent-device.mjs
+ checksum: 10/bcb4159faaa1d6fc352298bb7bd80dff402ad29dce4c877eb8c82c6e7f6e05bddcc5b9a5f954a041ed8638301c5c18faebc6086b89e7c499314033c975de4e71
+ languageName: node
+ linkType: hard
+
"agentkeepalive@npm:^4.5.0":
version: 4.6.0
resolution: "agentkeepalive@npm:4.6.0"
@@ -29496,12 +29515,13 @@ __metadata:
languageName: node
linkType: hard
-"fast-xml-builder@npm:^1.1.4":
- version: 1.1.4
- resolution: "fast-xml-builder@npm:1.1.4"
+"fast-xml-builder@npm:^1.1.7":
+ version: 1.2.0
+ resolution: "fast-xml-builder@npm:1.2.0"
dependencies:
- path-expression-matcher: "npm:^1.1.3"
- checksum: 10/32937866aaf5a90e69d1f4ee6e15e875248d5b5d2afd70277e9e8323074de4980cef24575a591b8e43c29f405d5f12377b3bad3842dc412b0c5c17a3eaee4b6b
+ path-expression-matcher: "npm:^1.5.0"
+ xml-naming: "npm:^0.1.0"
+ checksum: 10/5948add7796879d03b6c779cbb17f2f203a41cdf23dfaaa4789c65078a36376cd0709a6586701e980e3d244ebd5fdb35db1235ccb5e4fb9e9abfd8c51e7b8813
languageName: node
linkType: hard
@@ -29516,16 +29536,17 @@ __metadata:
languageName: node
linkType: hard
-"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.5.6":
- version: 5.5.9
- resolution: "fast-xml-parser@npm:5.5.9"
+"fast-xml-parser@npm:^5.3.3, fast-xml-parser@npm:^5.5.6, fast-xml-parser@npm:^5.7.2":
+ version: 5.7.3
+ resolution: "fast-xml-parser@npm:5.7.3"
dependencies:
- fast-xml-builder: "npm:^1.1.4"
- path-expression-matcher: "npm:^1.2.0"
- strnum: "npm:^2.2.2"
+ "@nodable/entities": "npm:^2.1.0"
+ fast-xml-builder: "npm:^1.1.7"
+ path-expression-matcher: "npm:^1.5.0"
+ strnum: "npm:^2.2.3"
bin:
fxparser: src/cli/cli.js
- checksum: 10/5f1a1a8b524406af21e9adb24f846b0da6b629c86b1eeedb54757cc293c24ed4f79ff9570b82206265b6951d68acd2dc93e74687ea5d7da0beafa09536cee73f
+ checksum: 10/00a58655d0d58c1f914c7fd8e3a94e88799c3d473e29a6d2231dc02103df069e8c6043137cbec8df1cda6525a39914d1b84455a79530f63be266876a2211251c
languageName: node
linkType: hard
@@ -35429,6 +35450,7 @@ __metadata:
"@wdio/protocols": "npm:^9.27.0"
"@welldone-software/why-did-you-render": "npm:^8.0.1"
"@xmldom/xmldom": "npm:^0.8.13"
+ agent-device: "npm:0.14.8"
appium: "npm:^2.5.4"
appium-adb: "npm:^9.11.4"
appium-chromium-driver: "npm:^2.0.2"
@@ -38448,10 +38470,10 @@ __metadata:
languageName: node
linkType: hard
-"path-expression-matcher@npm:^1.1.3, path-expression-matcher@npm:^1.2.0":
- version: 1.2.0
- resolution: "path-expression-matcher@npm:1.2.0"
- checksum: 10/eab23babd9a97d6cf4841a99825c3e990b70b2b29ea6529df9fb6a1f3953befbc68e9e282a373d7a75aff5dc6542d05a09ee2df036ff9bfddf5e1627b769875b
+"path-expression-matcher@npm:^1.5.0":
+ version: 1.5.0
+ resolution: "path-expression-matcher@npm:1.5.0"
+ checksum: 10/28303bb9ee6831e6df14c10cd3f3f7b2d7c8d7f788d8bdb7440136fd696064c82a3e264999a0764d28e39f698275fc03a5493bec93c57ef4a22566280367dd64
languageName: node
linkType: hard
@@ -43979,10 +44001,10 @@ __metadata:
languageName: node
linkType: hard
-"strnum@npm:^2.2.2":
- version: 2.2.2
- resolution: "strnum@npm:2.2.2"
- checksum: 10/c55813cfded750dc84556b4881ffc7cee91382ff15a48f1fba0ff7a678e1640ed96ca40806fbd55724940fd7d51cf752469b2d862e196e4adefb6c7d5d9cd73b
+"strnum@npm:^2.2.3":
+ version: 2.3.0
+ resolution: "strnum@npm:2.3.0"
+ checksum: 10/ce79c86bb2b96f053eb28e14924c13604e22977dcdece9aa914c25e16cc5c4bbe048976fe0b2a4decf08a1e13600b820749cea25463fc0e5fee3078339e0a457
languageName: node
linkType: hard
@@ -47082,6 +47104,13 @@ __metadata:
languageName: node
linkType: hard
+"xml-naming@npm:^0.1.0":
+ version: 0.1.0
+ resolution: "xml-naming@npm:0.1.0"
+ checksum: 10/45abd94ba64a508bda3f4d0b70e49811a3c3542596252c213caf47c858bbe9bba365ebba8eeff68e2a876e22a1bf6855d90cd2019b2f28012cebb167a4df2293
+ languageName: node
+ linkType: hard
+
"xml2js@npm:0.6.0":
version: 0.6.0
resolution: "xml2js@npm:0.6.0"
From e2a3cf83039046352dfedf963640098b65bbdd80 Mon Sep 17 00:00:00 2001
From: Alexey Kureev
Date: Mon, 25 May 2026 16:42:54 +0200
Subject: [PATCH 5/6] feat(money): add Money Account transaction toasts
(MUSD-810) (#30420)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## **Description**
Adds toast notifications for Money Account deposit and withdrawal
transactions, mirroring the existing Earn (`useEarnToasts` /
`useMusdConversionStatus`) pattern.
A new `useMoneyTransactionStatus` hook subscribes to
`TransactionController:transactionStatusUpdated` and
`transactionConfirmed`, filters for
`TransactionType.moneyAccountDeposit` and
`TransactionType.moneyAccountWithdraw`, deduplicates by transaction id +
status, and surfaces:
- `approved` → "Transaction in progress" (spinner, persistent)
- `confirmed` → "Transaction complete" with the decoded mUSD amount
formatted as fiat. Falls back to a `X.XX mUSD` label when no fiat rate
is available so the toast still surfaces a real value.
- `failed` → "Transaction failed" with a DSRN Primary "Try again"
button. The button navigates the user to the relevant Money picker sheet
(Add money / Transfer) so they can re-initiate; a true retry that
preserves the prior amount/token is tracked as a follow-up.
The hook is mounted globally via `` placed
alongside `` in `Nav/Main/index.js` so toasts
surface even after the user has navigated away from Money screens. Retry
navigation goes through `NavigationService` (not `useNavigation`)
because the hook runs outside the `MainNavigator`'s screen scope.
Active transaction types covered today: `moneyAccountDeposit` (Convert
crypto + Move mUSD) and `moneyAccountWithdraw` (Between accounts). Out
of scope: Ramp "Deposit funds" purchases (no `moneyAccountDeposit`
transaction is dispatched) and Perps / Predict transfers (currently
"Under construction" stubs; a TODO in `useMoneyTransactionStatus.ts`
marks where to derive the withdraw success destination once they ship).
## **Changelog**
CHANGELOG entry: Added in-app toasts for Money Account deposit and
withdrawal transactions, including a "Try again" action when a
transaction fails.
## **Related issues**
Fixes:
[MUSD-810](https://consensyssoftware.atlassian.net/browse/MUSD-810)
## **Manual testing steps**
```gherkin
Feature: Money Account transaction toasts
Scenario: User completes a Money Account deposit
Given the user is on the Money home screen with a non-zero source balance
When the user taps "Add money" → "Convert crypto" and confirms the conversion
Then a "Transaction in progress" toast appears with a spinner
And when the transaction confirms, a "Transaction complete" toast appears
And the body reads "{amount} added to Money account."
Scenario: User completes a Money Account withdrawal
Given the user has a non-zero Money Account balance
When the user taps "Transfer" → "Between accounts" and confirms
Then a "Transaction in progress" toast appears with a spinner
And when the transaction confirms, a "Transaction complete" toast appears
And the body reads "{amount} moved to Between accounts."
Scenario: A deposit transaction fails
Given the user has confirmed a deposit
When the transaction fails on-chain
Then a "Transaction failed" toast appears with a "Try again" button
And tapping "Try again" navigates to the Add money picker sheet
Scenario: A withdrawal transaction fails
Given the user has confirmed a withdrawal
When the transaction fails on-chain
Then a "Transaction failed" toast appears with a "Try again" button
And tapping "Try again" navigates to the Transfer picker sheet
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [x] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests if applicable
- [x] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [x] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- [x] I've tested with a power user scenario
- [x] I've instrumented key operations with Sentry traces for production
performance metrics
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
[MUSD-810]:
https://consensyssoftware.atlassian.net/browse/MUSD-810?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ
---
> [!NOTE]
> **Medium Risk**
> Adds global transaction event listeners and suppresses existing in-app
notifications for `moneyAccountDeposit`/`moneyAccountWithdraw`, which
could impact user-facing transaction feedback and requires careful
validation across transaction lifecycles (including batched
transactions).
>
> **Overview**
> **Money Account deposits/withdrawals now surface in-app toasts** for
`approved` (deferred in-progress), `confirmed` (success with
decoded/fiat-formatted mUSD amount), and `failed` (error) transaction
states via a new `useMoneyTransactionStatus` hook and
`MoneyTransactionMonitor` mounted in `Nav/Main`.
>
> This introduces a new `useMoneyToasts` builder for consistent toast UI
+ haptics and adds corresponding i18n strings, while also updating
`NotificationManager` to *skip* legacy transaction notifications for
`moneyAccountDeposit` and `moneyAccountWithdraw` and exporting
`TELLER_ABI` for calldata decoding. Tests were added to cover toast
option building, event subscription/dedup/timer cleanup, and
batch/nested transaction handling.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
197e27edd17395423ab068bde4afa711e1a6ada2. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
app/components/Nav/Main/index.js | 2 +
app/components/Nav/Main/index.test.tsx | 5 +
.../MoneyTransactionMonitor.test.tsx | 36 +
.../MoneyTransactionMonitor.tsx | 9 +
.../UI/Money/hooks/useMoneyToasts.test.tsx | 266 +++++++
.../UI/Money/hooks/useMoneyToasts.tsx | 291 ++++++++
.../hooks/useMoneyTransactionStatus.test.ts | 665 ++++++++++++++++++
.../Money/hooks/useMoneyTransactionStatus.ts | 275 ++++++++
.../Money/utils/moneyAccountTransactions.ts | 2 +-
app/core/NotificationManager.js | 2 +
locales/languages/en.json | 13 +
11 files changed, 1565 insertions(+), 1 deletion(-)
create mode 100644 app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
create mode 100644 app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
create mode 100644 app/components/UI/Money/hooks/useMoneyToasts.test.tsx
create mode 100644 app/components/UI/Money/hooks/useMoneyToasts.tsx
create mode 100644 app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
create mode 100644 app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js
index e75f841f0782..406b078070c9 100644
--- a/app/components/Nav/Main/index.js
+++ b/app/components/Nav/Main/index.js
@@ -37,6 +37,7 @@ import ProtectYourWalletModal from '../../UI/ProtectYourWalletModal';
import MainNavigator from './MainNavigator';
import { query } from '@metamask/controller-utils';
import EarnTransactionMonitor from '../../UI/Earn/components/EarnTransactionMonitor';
+import MoneyTransactionMonitor from '../../UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor';
import {
setInfuraAvailabilityBlocked,
@@ -425,6 +426,7 @@ const Main = (props) => {
+
{renderDeprecatedNetworkAlert(
props.chainId,
props.backUpSeedphraseVisible,
diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx
index 642344d59beb..9b8d208b42f6 100644
--- a/app/components/Nav/Main/index.test.tsx
+++ b/app/components/Nav/Main/index.test.tsx
@@ -66,6 +66,11 @@ jest.mock(
() => () => mockReact.createElement('EarnTransactionMonitorMock'),
);
+jest.mock(
+ '../../UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor',
+ () => () => mockReact.createElement('MoneyTransactionMonitorMock'),
+);
+
jest.mock('../../UI/ProtectYourWalletModal', () => ({
__esModule: true,
default: () => mockReact.createElement('ProtectYourWalletModalMock'),
diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
new file mode 100644
index 000000000000..cfad4916ba22
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.test.tsx
@@ -0,0 +1,36 @@
+import React from 'react';
+import { render } from '@testing-library/react-native';
+import MoneyTransactionMonitor from './MoneyTransactionMonitor';
+import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus';
+
+jest.mock('../../hooks/useMoneyTransactionStatus');
+
+describe('MoneyTransactionMonitor', () => {
+ const mockUseMoneyTransactionStatus = jest.mocked(useMoneyTransactionStatus);
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('renders without crashing', () => {
+ const result = render();
+
+ expect(result).toBeDefined();
+ });
+
+ it('calls useMoneyTransactionStatus exactly once', () => {
+ render();
+
+ expect(mockUseMoneyTransactionStatus).toHaveBeenCalledTimes(1);
+ });
+
+ it('returns null', () => {
+ const { toJSON } = render();
+
+ expect(toJSON()).toBeNull();
+ });
+});
diff --git a/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
new file mode 100644
index 000000000000..3602db968375
--- /dev/null
+++ b/app/components/UI/Money/components/MoneyTransactionMonitor/MoneyTransactionMonitor.tsx
@@ -0,0 +1,9 @@
+import React from 'react';
+import { useMoneyTransactionStatus } from '../../hooks/useMoneyTransactionStatus';
+
+const MoneyTransactionMonitor: React.FC = () => {
+ useMoneyTransactionStatus();
+ return null;
+};
+
+export default MoneyTransactionMonitor;
diff --git a/app/components/UI/Money/hooks/useMoneyToasts.test.tsx b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx
new file mode 100644
index 000000000000..5aa573cdaa9b
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyToasts.test.tsx
@@ -0,0 +1,266 @@
+import React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { playNotification, NotificationMoment } from '../../../../util/haptics';
+import useMoneyToasts from './useMoneyToasts';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { ButtonIconProps } from '../../../../component-library/components/Buttons/ButtonIcon/ButtonIcon.types';
+
+jest.mock('../../../../util/haptics');
+
+jest.mock('../../../../util/theme', () => {
+ const actual = jest.requireActual('../../../../util/theme');
+ return {
+ ...actual,
+ useAppThemeFromContext: jest.fn(() => actual.mockTheme),
+ };
+});
+
+describe('useMoneyToasts', () => {
+ const mockShowToast = jest.fn();
+ const mockCloseToast = jest.fn();
+ const mockToastRef = {
+ current: {
+ showToast: mockShowToast,
+ closeToast: mockCloseToast,
+ },
+ };
+
+ const mockPlayNotification = jest.mocked(playNotification);
+
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ afterEach(() => {
+ jest.resetAllMocks();
+ });
+
+ describe('showToast', () => {
+ it('calls toastRef.current.showToast with toast options', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const testConfig = result.current.MoneyToastOptions.deposit.success({
+ amountFiat: '$10.00',
+ });
+
+ result.current.showToast(testConfig);
+
+ expect(mockShowToast).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(
+ expect.objectContaining({
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ }),
+ );
+ });
+
+ it('triggers haptics with correct type', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const testConfig = result.current.MoneyToastOptions.deposit.success({
+ amountFiat: '$10.00',
+ });
+
+ result.current.showToast(testConfig);
+
+ expect(mockPlayNotification).toHaveBeenCalledTimes(1);
+ expect(mockPlayNotification).toHaveBeenCalledWith(
+ NotificationMoment.Success,
+ );
+ });
+
+ it('excludes hapticsType from toast options passed to toastRef', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const testConfig = result.current.MoneyToastOptions.deposit.inProgress();
+
+ result.current.showToast(testConfig);
+
+ const callArgs = mockShowToast.mock.calls[0][0];
+ expect(callArgs).not.toHaveProperty('hapticsType');
+ });
+ });
+
+ describe('MoneyToastOptions structure', () => {
+ it('exposes deposit and withdraw namespaces with all three builders', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ expect(result.current.MoneyToastOptions.deposit).toBeDefined();
+ expect(result.current.MoneyToastOptions.deposit.inProgress).toBeDefined();
+ expect(result.current.MoneyToastOptions.deposit.success).toBeDefined();
+ expect(result.current.MoneyToastOptions.deposit.failed).toBeDefined();
+
+ expect(result.current.MoneyToastOptions.withdraw).toBeDefined();
+ expect(
+ result.current.MoneyToastOptions.withdraw.inProgress,
+ ).toBeDefined();
+ expect(result.current.MoneyToastOptions.withdraw.success).toBeDefined();
+ expect(result.current.MoneyToastOptions.withdraw.failed).toBeDefined();
+ });
+ });
+
+ describe('deposit toasts', () => {
+ it('inProgress has Loading icon, Warning haptics and persists until dismissed', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.deposit.inProgress();
+
+ expect(toast.variant).toBe(ToastVariants.Icon);
+ expect(toast.iconName).toBe(IconName.Loading);
+ expect(toast.hapticsType).toBe(NotificationMoment.Warning);
+ expect(toast.hasNoTimeout).toBe(true);
+ expect(toast.startAccessory).toBeDefined();
+ expect(toast.labelOptions).toHaveLength(3);
+ });
+
+ it('success has Confirmation icon, Success haptics and includes amount in body', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.deposit.success({
+ amountFiat: '$25.00',
+ });
+
+ expect(toast.variant).toBe(ToastVariants.Icon);
+ expect(toast.iconName).toBe(IconName.Confirmation);
+ expect(toast.iconColor).toBeDefined();
+ expect(toast.hapticsType).toBe(NotificationMoment.Success);
+ expect(toast.labelOptions).toHaveLength(3);
+ expect(toast.labelOptions?.[0].label).toEqual(expect.any(String));
+ });
+
+ it('failed has CircleX icon, Error haptics and a descriptive body', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.deposit.failed();
+
+ expect(toast.variant).toBe(ToastVariants.Icon);
+ expect(toast.iconName).toBe(IconName.CircleX);
+ expect(toast.iconColor).toBeDefined();
+ expect(toast.hapticsType).toBe(NotificationMoment.Error);
+ expect(toast.labelOptions).toHaveLength(3);
+ expect(toast.labelOptions?.[0].label).toEqual(expect.any(String));
+ });
+ });
+
+ describe('withdraw toasts', () => {
+ it('inProgress mirrors the deposit in-progress configuration', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.withdraw.inProgress();
+
+ expect(toast.variant).toBe(ToastVariants.Icon);
+ expect(toast.iconName).toBe(IconName.Loading);
+ expect(toast.hapticsType).toBe(NotificationMoment.Warning);
+ expect(toast.hasNoTimeout).toBe(true);
+ });
+
+ it('success includes both amount and destination in body', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.withdraw.success({
+ amountFiat: '$50.00',
+ destination: 'Between accounts',
+ });
+
+ expect(toast.iconName).toBe(IconName.Confirmation);
+ expect(toast.hapticsType).toBe(NotificationMoment.Success);
+ expect(toast.labelOptions).toHaveLength(3);
+ });
+
+ it('failed surfaces an error toast with the withdraw-specific body', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.withdraw.failed();
+
+ expect(toast.iconName).toBe(IconName.CircleX);
+ expect(toast.hapticsType).toBe(NotificationMoment.Error);
+ expect(toast.labelOptions).toHaveLength(3);
+ expect(toast.labelOptions?.[0].label).toEqual(expect.any(String));
+ });
+ });
+
+ describe('closeButtonOptions', () => {
+ it.each([
+ ['deposit.inProgress', () => ({}), 'inProgress'],
+ ['deposit.success', () => ({ amountFiat: '$1.00' }), 'success'],
+ ['deposit.failed', () => ({}), 'failed'],
+ ['withdraw.inProgress', () => ({}), 'inProgress'],
+ [
+ 'withdraw.success',
+ () => ({ amountFiat: '$1.00', destination: 'Between accounts' }),
+ 'success',
+ ],
+ ['withdraw.failed', () => ({}), 'failed'],
+ ])('exposes a Close button on %s', (key, paramsFactory, _builder) => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+ const [namespace, builder] = key.split('.') as [
+ 'deposit' | 'withdraw',
+ 'inProgress' | 'success' | 'failed',
+ ];
+
+ const params = paramsFactory() as never;
+ const toast =
+ result.current.MoneyToastOptions[namespace][builder](params);
+
+ expect(toast.closeButtonOptions).toBeDefined();
+ expect((toast.closeButtonOptions as ButtonIconProps)?.iconName).toBe(
+ IconName.Close,
+ );
+ });
+
+ it('calls closeToast when closeButtonOptions.onPress is invoked', () => {
+ const { result } = renderHook(() => useMoneyToasts(), { wrapper });
+
+ const toast = result.current.MoneyToastOptions.deposit.inProgress();
+
+ toast.closeButtonOptions?.onPress?.();
+
+ expect(mockCloseToast).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles missing toastRef gracefully on showToast', () => {
+ const emptyWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useMoneyToasts(), {
+ wrapper: emptyWrapper,
+ });
+
+ const toast = result.current.MoneyToastOptions.deposit.success({
+ amountFiat: '$1.00',
+ });
+
+ expect(() => result.current.showToast(toast)).not.toThrow();
+ expect(mockPlayNotification).toHaveBeenCalled();
+ });
+
+ it('handles closeToast with null toastRef gracefully', () => {
+ const emptyWrapper = ({ children }: { children: React.ReactNode }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useMoneyToasts(), {
+ wrapper: emptyWrapper,
+ });
+
+ const toast = result.current.MoneyToastOptions.deposit.inProgress();
+
+ expect(() => toast.closeButtonOptions?.onPress?.()).not.toThrow();
+ });
+ });
+});
diff --git a/app/components/UI/Money/hooks/useMoneyToasts.tsx b/app/components/UI/Money/hooks/useMoneyToasts.tsx
new file mode 100644
index 000000000000..07a6fd82e642
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyToasts.tsx
@@ -0,0 +1,291 @@
+import {
+ playNotification,
+ NotificationMoment,
+ type HapticNotificationMoment,
+} from '../../../../util/haptics';
+import React, { useCallback, useContext, useMemo } from 'react';
+import { StyleSheet, View } from 'react-native';
+import { strings } from '../../../../../locales/i18n';
+import Icon, {
+ IconName,
+ IconSize,
+} from '../../../../component-library/components/Icons/Icon';
+import { ToastContext } from '../../../../component-library/components/Toast';
+import {
+ ButtonIconVariant,
+ ToastOptions,
+ ToastVariants,
+} from '../../../../component-library/components/Toast/Toast.types';
+import { useAppThemeFromContext } from '../../../../util/theme';
+import {
+ Spinner,
+ IconSize as ReactNativeDsIconSize,
+ Text,
+ TextColor,
+ TextVariant,
+} from '@metamask/design-system-react-native';
+
+export type MoneyToastOptions = Omit<
+ Extract,
+ 'labelOptions'
+> & {
+ hapticsType: HapticNotificationMoment;
+ labelOptions?: {
+ label: string | React.ReactNode;
+ isBold?: boolean;
+ }[];
+};
+
+export interface DepositSuccessParams {
+ amountFiat?: string;
+}
+
+export interface WithdrawSuccessParams {
+ amountFiat?: string;
+ destination: string;
+}
+
+export interface MoneyToastOptionsConfig {
+ deposit: {
+ inProgress: () => MoneyToastOptions;
+ success: (params: DepositSuccessParams) => MoneyToastOptions;
+ failed: () => MoneyToastOptions;
+ };
+ withdraw: {
+ inProgress: () => MoneyToastOptions;
+ success: (params: WithdrawSuccessParams) => MoneyToastOptions;
+ failed: () => MoneyToastOptions;
+ };
+}
+
+interface MoneyToastLabelOptions {
+ primary: string | React.ReactNode;
+ secondary: string | React.ReactNode;
+ primaryIsBold?: boolean;
+}
+
+const getMoneyToastLabels = ({
+ primary,
+ secondary,
+ primaryIsBold = false,
+}: MoneyToastLabelOptions) => [
+ { label: primary, isBold: primaryIsBold },
+ { label: '\n', isBold: false },
+ { label: secondary, isBold: false },
+];
+
+const MONEY_TOASTS_DEFAULT_OPTIONS: Partial = {
+ hasNoTimeout: false,
+};
+
+const toastStyles = StyleSheet.create({
+ iconWrapper: {
+ marginRight: 16,
+ },
+});
+
+const useMoneyToasts = (): {
+ showToast: (config: MoneyToastOptions) => void;
+ MoneyToastOptions: MoneyToastOptionsConfig;
+} => {
+ const { toastRef } = useContext(ToastContext);
+ const theme = useAppThemeFromContext();
+
+ const closeToast = useCallback(() => {
+ toastRef?.current?.closeToast();
+ }, [toastRef]);
+
+ const closeButtonOptions = useMemo(
+ () => ({
+ variant: ButtonIconVariant.Icon,
+ iconName: IconName.Close,
+ onPress: closeToast,
+ }),
+ [closeToast],
+ );
+
+ const moneyBaseToastOptions: Record = useMemo(
+ () => ({
+ success: {
+ ...(MONEY_TOASTS_DEFAULT_OPTIONS as MoneyToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.Confirmation,
+ iconColor: theme.colors.success.default,
+ hapticsType: NotificationMoment.Success,
+ startAccessory: (
+
+
+
+ ),
+ },
+ inProgress: {
+ ...(MONEY_TOASTS_DEFAULT_OPTIONS as MoneyToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.Loading,
+ hapticsType: NotificationMoment.Warning,
+ hasNoTimeout: true,
+ startAccessory: (
+
+
+
+ ),
+ },
+ error: {
+ ...(MONEY_TOASTS_DEFAULT_OPTIONS as MoneyToastOptions),
+ variant: ToastVariants.Icon,
+ iconName: IconName.CircleX,
+ iconColor: theme.colors.error.default,
+ hapticsType: NotificationMoment.Error,
+ startAccessory: (
+
+
+
+ ),
+ },
+ }),
+ [theme],
+ );
+
+ const showToast = useCallback(
+ (config: MoneyToastOptions) => {
+ const { hapticsType, ...toastOptions } = config;
+ toastRef?.current?.showToast(toastOptions as ToastOptions);
+ playNotification(hapticsType);
+ },
+ [toastRef],
+ );
+
+ const MoneyToastOptions: MoneyToastOptionsConfig = useMemo(
+ () => ({
+ deposit: {
+ inProgress: () => ({
+ ...moneyBaseToastOptions.inProgress,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.in_progress_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {strings('money.toasts.in_progress_body')}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ success: ({ amountFiat }: DepositSuccessParams) => ({
+ ...moneyBaseToastOptions.success,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.success_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {amountFiat
+ ? strings('money.toasts.deposit_success_body', {
+ amount: amountFiat,
+ })
+ : strings('money.toasts.deposit_success_body_no_amount')}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ failed: () => ({
+ ...moneyBaseToastOptions.error,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.deposit_failed_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {strings('money.toasts.deposit_failed_body')}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ },
+ withdraw: {
+ inProgress: () => ({
+ ...moneyBaseToastOptions.inProgress,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.in_progress_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {strings('money.toasts.in_progress_body')}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ success: ({ amountFiat, destination }: WithdrawSuccessParams) => ({
+ ...moneyBaseToastOptions.success,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.success_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {amountFiat
+ ? strings('money.toasts.withdraw_success_body', {
+ amount: amountFiat,
+ destination,
+ })
+ : strings('money.toasts.withdraw_success_body_no_amount', {
+ destination,
+ })}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ failed: () => ({
+ ...moneyBaseToastOptions.error,
+ labelOptions: getMoneyToastLabels({
+ primary: strings('money.toasts.withdraw_failed_title'),
+ primaryIsBold: true,
+ secondary: (
+
+ {strings('money.toasts.withdraw_failed_body')}
+
+ ),
+ }),
+ closeButtonOptions,
+ }),
+ },
+ }),
+ [
+ closeButtonOptions,
+ moneyBaseToastOptions.error,
+ moneyBaseToastOptions.inProgress,
+ moneyBaseToastOptions.success,
+ ],
+ );
+
+ return { showToast, MoneyToastOptions };
+};
+
+export default useMoneyToasts;
diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
new file mode 100644
index 000000000000..b3a08e74caa9
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.test.ts
@@ -0,0 +1,665 @@
+import {
+ TransactionMeta,
+ TransactionStatus,
+ TransactionType,
+} from '@metamask/transaction-controller';
+import { renderHook } from '@testing-library/react-hooks';
+import { ethers } from 'ethers';
+import Engine from '../../../../core/Engine';
+import {
+ useMoneyTransactionStatus,
+ formatMusdAmountForToast,
+ IN_PROGRESS_DELAY_MS,
+} from './useMoneyTransactionStatus';
+import useMoneyToasts, { MoneyToastOptionsConfig } from './useMoneyToasts';
+import { ToastVariants } from '../../../../component-library/components/Toast/Toast.types';
+import { IconName } from '../../../../component-library/components/Icons/Icon';
+import { NotificationMoment } from '../../../../util/haptics';
+import { TOAST_TRACKING_CLEANUP_DELAY_MS } from '../../Earn/constants/musd';
+
+jest.mock('../../../../core/Engine');
+jest.mock('./useMoneyToasts');
+jest.mock('../../../../store', () => ({
+ store: { getState: jest.fn(() => ({})) },
+}));
+jest.mock('../../../../util/Logger', () => ({
+ __esModule: true,
+ default: { error: jest.fn() },
+}));
+jest.mock('../../../../selectors/tokenRatesController', () => ({
+ ...jest.requireActual('../../../../selectors/tokenRatesController'),
+ selectTokenMarketData: jest.fn(() => undefined),
+}));
+jest.mock('../../../../selectors/currencyRateController', () => ({
+ ...jest.requireActual('../../../../selectors/currencyRateController'),
+ selectCurrencyRates: jest.fn(() => undefined),
+ selectCurrentCurrency: jest.fn(() => 'usd'),
+}));
+jest.mock('../../../../selectors/networkController', () => ({
+ ...jest.requireActual('../../../../selectors/networkController'),
+ selectNetworkConfigurations: jest.fn(() => undefined),
+}));
+jest.mock('../../../../util/theme', () => ({
+ useAppThemeFromContext: jest.fn(() => ({
+ colors: {
+ success: { default: '#success' },
+ error: { default: '#error' },
+ icon: { default: '#icon' },
+ background: { default: '#bg' },
+ primary: { default: '#primary' },
+ },
+ })),
+ mockTheme: {
+ colors: {
+ success: { default: '#success' },
+ error: { default: '#error' },
+ icon: { default: '#icon' },
+ background: { default: '#bg' },
+ primary: { default: '#primary' },
+ },
+ },
+}));
+
+type TransactionStatusUpdatedHandler = (event: {
+ transactionMeta: TransactionMeta;
+}) => void;
+type TransactionConfirmedHandler = (transactionMeta: TransactionMeta) => void;
+
+const mockSubscribe = jest.fn<
+ void,
+ [string, TransactionStatusUpdatedHandler | TransactionConfirmedHandler]
+>();
+const mockUnsubscribe = jest.fn<
+ void,
+ [string, TransactionStatusUpdatedHandler | TransactionConfirmedHandler]
+>();
+
+Object.defineProperty(Engine, 'controllerMessenger', {
+ value: { subscribe: mockSubscribe, unsubscribe: mockUnsubscribe },
+ writable: true,
+ configurable: true,
+});
+
+const mockUseMoneyToasts = jest.mocked(useMoneyToasts);
+
+const TELLER_INTERFACE = new ethers.utils.Interface([
+ 'function deposit(address depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) payable returns (uint256 shares)',
+ 'function withdraw(address withdrawAsset, uint256 shareAmount, uint256 minimumAssets, address to) returns (uint256 assetsOut)',
+]);
+
+const MUSD_ADDRESS = '0xaca92e438df0b2401ff60da7e4337b687a2435da';
+
+const encodeDepositData = (amountWei: bigint) =>
+ TELLER_INTERFACE.encodeFunctionData('deposit', [
+ MUSD_ADDRESS,
+ amountWei.toString(),
+ '0',
+ '0x0000000000000000000000000000000000000000',
+ ]);
+
+const encodeWithdrawData = (amountWei: bigint) =>
+ TELLER_INTERFACE.encodeFunctionData('withdraw', [
+ MUSD_ADDRESS,
+ amountWei.toString(),
+ '0',
+ '0x0000000000000000000000000000000000000000',
+ ]);
+
+const buildTxMeta = (overrides: Partial): TransactionMeta =>
+ ({
+ id: 'tx-id-1',
+ chainId: '0x1',
+ status: TransactionStatus.unapproved,
+ type: TransactionType.moneyAccountDeposit,
+ txParams: { from: '0x0', data: '0x' },
+ ...overrides,
+ }) as unknown as TransactionMeta;
+
+describe('useMoneyTransactionStatus', () => {
+ const mockShowToast = jest.fn();
+
+ const baseInProgressToast = {
+ variant: ToastVariants.Icon as const,
+ iconName: IconName.Loading,
+ hasNoTimeout: true,
+ hapticsType: NotificationMoment.Warning,
+ labelOptions: [{ label: 'In progress', isBold: true }],
+ };
+ const baseSuccessToast = {
+ variant: ToastVariants.Icon as const,
+ iconName: IconName.Confirmation,
+ hasNoTimeout: false,
+ iconColor: '#success',
+ hapticsType: NotificationMoment.Success,
+ labelOptions: [{ label: 'Success', isBold: true }],
+ };
+ const baseFailedToast = {
+ variant: ToastVariants.Icon as const,
+ iconName: IconName.CircleX,
+ hasNoTimeout: false,
+ iconColor: '#error',
+ hapticsType: NotificationMoment.Error,
+ labelOptions: [{ label: 'Failed', isBold: true }],
+ };
+
+ const depositInProgressFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseInProgressToast);
+ const depositSuccessFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseSuccessToast);
+ const depositFailedFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseFailedToast);
+ const withdrawInProgressFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseInProgressToast);
+ const withdrawSuccessFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseSuccessToast);
+ const withdrawFailedFn = jest.fn<
+ ReturnType,
+ Parameters
+ >(() => baseFailedToast);
+
+ const moneyToastOptions: MoneyToastOptionsConfig = {
+ deposit: {
+ inProgress: depositInProgressFn,
+ success: depositSuccessFn,
+ failed: depositFailedFn,
+ },
+ withdraw: {
+ inProgress: withdrawInProgressFn,
+ success: withdrawSuccessFn,
+ failed: withdrawFailedFn,
+ },
+ };
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+
+ mockUseMoneyToasts.mockReturnValue({
+ showToast: mockShowToast,
+ MoneyToastOptions: moneyToastOptions,
+ });
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ jest.useRealTimers();
+ });
+
+ const renderAndGetHandlers = () => {
+ renderHook(() => useMoneyTransactionStatus());
+ const findHandler = (eventName: string) =>
+ mockSubscribe.mock.calls.find(([event]) => event === eventName)?.[1];
+ return {
+ statusUpdatedHandler: findHandler(
+ 'TransactionController:transactionStatusUpdated',
+ ) as TransactionStatusUpdatedHandler,
+ confirmedHandler: findHandler(
+ 'TransactionController:transactionConfirmed',
+ ) as TransactionConfirmedHandler,
+ };
+ };
+
+ it('subscribes to and unsubscribes from all transaction events', () => {
+ const events = [
+ 'TransactionController:transactionStatusUpdated',
+ 'TransactionController:transactionConfirmed',
+ ];
+
+ const { unmount } = renderHook(() => useMoneyTransactionStatus());
+
+ events.forEach((event) => {
+ expect(mockSubscribe).toHaveBeenCalledWith(event, expect.any(Function));
+ });
+
+ unmount();
+
+ events.forEach((event) => {
+ expect(mockUnsubscribe).toHaveBeenCalledWith(event, expect.any(Function));
+ });
+ });
+
+ it('ignores non-Money Account transaction types', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.simpleSend,
+ status: TransactionStatus.approved,
+ }),
+ });
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ });
+
+ describe('deposit lifecycle', () => {
+ it('approved → in-progress toast (after deferral)', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ }),
+ });
+
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).toHaveBeenCalledTimes(1);
+ expect(mockShowToast).toHaveBeenCalledWith(baseInProgressToast);
+ });
+
+ it('confirmed → success toast with decoded fiat amount', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.confirmed,
+ txParams: {
+ from: '0x0',
+ data: encodeDepositData(BigInt(12_340_000)),
+ },
+ }),
+ );
+
+ expect(depositSuccessFn).toHaveBeenCalledTimes(1);
+ const params = depositSuccessFn.mock.calls[0][0];
+ expect(params.amountFiat).toContain('mUSD');
+ expect(params.amountFiat).toContain('12.34');
+ });
+
+ it('failed → deposit failed toast', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.failed,
+ }),
+ });
+
+ expect(depositFailedFn).toHaveBeenCalledTimes(1);
+ expect(withdrawFailedFn).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ ['dropped', TransactionStatus.dropped],
+ ['rejected', TransactionStatus.rejected],
+ ['cancelled', TransactionStatus.cancelled],
+ ])('statusUpdated with %s → deposit failed toast', (_label, status) => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status,
+ }),
+ });
+
+ expect(depositFailedFn).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('withdraw lifecycle', () => {
+ it('approved → in-progress toast (withdraw namespace, after deferral)', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountWithdraw,
+ status: TransactionStatus.approved,
+ }),
+ });
+
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(withdrawInProgressFn).toHaveBeenCalledTimes(1);
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ });
+
+ it('confirmed → success toast with destination and decoded amount', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildTxMeta({
+ type: TransactionType.moneyAccountWithdraw,
+ status: TransactionStatus.confirmed,
+ txParams: {
+ from: '0x0',
+ data: encodeWithdrawData(BigInt(50_000_000)),
+ },
+ }),
+ );
+
+ expect(withdrawSuccessFn).toHaveBeenCalledTimes(1);
+ const params = withdrawSuccessFn.mock.calls[0][0];
+ expect(params.amountFiat).toContain('50.00');
+ expect(params.destination).toBeDefined();
+ });
+
+ it('failed → withdraw failed toast', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountWithdraw,
+ status: TransactionStatus.failed,
+ }),
+ });
+
+ expect(withdrawFailedFn).toHaveBeenCalledTimes(1);
+ expect(depositFailedFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('dedup + cleanup', () => {
+ it('does not fire the same status+id toast twice', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ const event = {
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ }),
+ };
+ statusUpdatedHandler(event);
+ statusUpdatedHandler(event);
+
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('allows the same id+status after the cleanup delay', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ const failedEvent = {
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.failed,
+ }),
+ };
+ statusUpdatedHandler(failedEvent);
+ expect(depositFailedFn).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(TOAST_TRACKING_CLEANUP_DELAY_MS + 1);
+
+ statusUpdatedHandler(failedEvent);
+ expect(depositFailedFn).toHaveBeenCalledTimes(2);
+ });
+
+ it('ignores transactionConfirmed events with non-confirmed status', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.failed,
+ }),
+ );
+
+ expect(depositSuccessFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('formatMusdAmountForToast', () => {
+ it('falls back to mUSD format when no fiat rate is available', () => {
+ expect(formatMusdAmountForToast(BigInt(1_000_000))).toBe('1.00 mUSD');
+ expect(formatMusdAmountForToast(BigInt(123_456))).toBe('0.12 mUSD');
+ });
+
+ it('formats as fiat when token market data, currency rates and network config resolve', () => {
+ const tokenRatesMock = jest.requireMock(
+ '../../../../selectors/tokenRatesController',
+ );
+ const currencyRatesMock = jest.requireMock(
+ '../../../../selectors/currencyRateController',
+ );
+ const networkConfigMock = jest.requireMock(
+ '../../../../selectors/networkController',
+ );
+ tokenRatesMock.selectTokenMarketData.mockReturnValueOnce({
+ '0x1': {
+ '0xacA92E438df0B2401fF60dA7E4337B687a2435DA': { price: 1 },
+ },
+ });
+ currencyRatesMock.selectCurrencyRates.mockReturnValueOnce({
+ ETH: { conversionRate: 2 },
+ });
+ networkConfigMock.selectNetworkConfigurations.mockReturnValueOnce({
+ '0x1': { nativeCurrency: 'ETH' },
+ });
+ currencyRatesMock.selectCurrentCurrency.mockReturnValueOnce('usd');
+
+ const formatted = formatMusdAmountForToast(BigInt(5_000_000));
+ expect(formatted).not.toContain('mUSD');
+ expect(formatted).toMatch(/10/);
+ });
+ });
+
+ describe('handler resilience', () => {
+ it('ignores non-terminal statuses (e.g. submitted) in transactionStatusUpdated', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.submitted,
+ }),
+ });
+
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('still shows success toast when txParams.data is malformed', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.confirmed,
+ txParams: { from: '0x0', data: '0xdeadbeef' },
+ }),
+ );
+
+ expect(depositSuccessFn).toHaveBeenCalledWith({ amountFiat: undefined });
+ });
+ });
+
+ describe('deferred in-progress', () => {
+ it('does not show in-progress when transaction confirms before the delay elapses', () => {
+ const { statusUpdatedHandler, confirmedHandler } = renderAndGetHandlers();
+
+ const tx = buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ txParams: { from: '0x0', data: encodeDepositData(BigInt(1_000_000)) },
+ });
+ statusUpdatedHandler({ transactionMeta: tx });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS - 1);
+ confirmedHandler({ ...tx, status: TransactionStatus.confirmed });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ expect(depositSuccessFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show in-progress when transaction fails before the delay elapses', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ const tx = buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ });
+ statusUpdatedHandler({ transactionMeta: tx });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS - 1);
+ statusUpdatedHandler({
+ transactionMeta: { ...tx, status: TransactionStatus.failed },
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ expect(depositFailedFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('does not show in-progress when transaction drops before the delay elapses', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ const tx = buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ });
+ statusUpdatedHandler({ transactionMeta: tx });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS - 1);
+ statusUpdatedHandler({
+ transactionMeta: { ...tx, status: TransactionStatus.dropped },
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ expect(depositFailedFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows in-progress after the delay when no terminal event arrives', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountWithdraw,
+ status: TransactionStatus.approved,
+ }),
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(withdrawInProgressFn).toHaveBeenCalledTimes(1);
+ });
+
+ it('clears pending in-progress timers on unmount', () => {
+ const { unmount } = renderHook(() => useMoneyTransactionStatus());
+ const statusUpdatedHandler = mockSubscribe.mock.calls.find(
+ ([event]) => event === 'TransactionController:transactionStatusUpdated',
+ )?.[1] as TransactionStatusUpdatedHandler;
+
+ statusUpdatedHandler({
+ transactionMeta: buildTxMeta({
+ type: TransactionType.moneyAccountDeposit,
+ status: TransactionStatus.approved,
+ }),
+ });
+
+ unmount();
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('EIP-7702 batched transactions', () => {
+ const batchTxWith = (
+ nestedType: TransactionType,
+ overrides: Partial = {},
+ ): TransactionMeta =>
+ ({
+ id: 'batch-tx-1',
+ chainId: '0x1',
+ status: TransactionStatus.unapproved,
+ type: TransactionType.batch,
+ txParams: { from: '0x0', data: '0x' },
+ nestedTransactions: [{ type: nestedType, data: '0x' }],
+ ...overrides,
+ }) as unknown as TransactionMeta;
+
+ it('treats type="batch" with nested moneyAccountDeposit as a deposit', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: batchTxWith(TransactionType.moneyAccountDeposit, {
+ status: TransactionStatus.approved,
+ }),
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(depositInProgressFn).toHaveBeenCalledTimes(1);
+ expect(withdrawInProgressFn).not.toHaveBeenCalled();
+ });
+
+ it('treats type="batch" with nested moneyAccountWithdraw as a withdraw', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: batchTxWith(TransactionType.moneyAccountWithdraw, {
+ status: TransactionStatus.failed,
+ }),
+ });
+
+ expect(withdrawFailedFn).toHaveBeenCalledTimes(1);
+ expect(depositFailedFn).not.toHaveBeenCalled();
+ });
+
+ it('ignores type="batch" without any Money-Account nested types', () => {
+ const { statusUpdatedHandler } = renderAndGetHandlers();
+
+ statusUpdatedHandler({
+ transactionMeta: batchTxWith(TransactionType.simpleSend, {
+ status: TransactionStatus.approved,
+ }),
+ });
+ jest.advanceTimersByTime(IN_PROGRESS_DELAY_MS);
+
+ expect(mockShowToast).not.toHaveBeenCalled();
+ });
+
+ it('decodes amount from nested deposit tx data on confirmation', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ batchTxWith(TransactionType.moneyAccountDeposit, {
+ status: TransactionStatus.confirmed,
+ nestedTransactions: [
+ {
+ type: TransactionType.moneyAccountDeposit,
+ data: encodeDepositData(BigInt(7_770_000)) as `0x${string}`,
+ },
+ ],
+ }),
+ );
+
+ expect(depositSuccessFn).toHaveBeenCalledTimes(1);
+ expect(depositSuccessFn.mock.calls[0][0].amountFiat).toContain('7.77');
+ });
+
+ it('decodes amount from nested withdraw tx data on confirmation', () => {
+ const { confirmedHandler } = renderAndGetHandlers();
+
+ confirmedHandler(
+ batchTxWith(TransactionType.moneyAccountWithdraw, {
+ status: TransactionStatus.confirmed,
+ nestedTransactions: [
+ {
+ type: TransactionType.moneyAccountWithdraw,
+ data: encodeWithdrawData(BigInt(33_330_000)) as `0x${string}`,
+ },
+ ],
+ }),
+ );
+
+ expect(withdrawSuccessFn).toHaveBeenCalledTimes(1);
+ expect(withdrawSuccessFn.mock.calls[0][0].amountFiat).toContain('33.33');
+ });
+ });
+});
diff --git a/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
new file mode 100644
index 000000000000..bc046ff40aa2
--- /dev/null
+++ b/app/components/UI/Money/hooks/useMoneyTransactionStatus.ts
@@ -0,0 +1,275 @@
+import {
+ CHAIN_IDS,
+ TransactionMeta,
+ TransactionStatus,
+ TransactionType,
+} from '@metamask/transaction-controller';
+import BigNumber from 'bignumber.js';
+import { ethers } from 'ethers';
+import { useEffect, useRef } from 'react';
+import Engine from '../../../../core/Engine';
+import Logger from '../../../../util/Logger';
+import { fromTokenMinimalUnitString } from '../../../../util/number/bigint';
+import { strings } from '../../../../../locales/i18n';
+import { store } from '../../../../store';
+import {
+ selectCurrencyRates,
+ selectCurrentCurrency,
+} from '../../../../selectors/currencyRateController';
+import { selectNetworkConfigurations } from '../../../../selectors/networkController';
+import { selectTokenMarketData } from '../../../../selectors/tokenRatesController';
+import { toChecksumAddress } from '../../../../util/address';
+import {
+ MUSD_DECIMALS,
+ MUSD_TOKEN_ADDRESS_BY_CHAIN,
+ TOAST_TRACKING_CLEANUP_DELAY_MS,
+} from '../../Earn/constants/musd';
+import { moneyFormatFiat } from '../utils/moneyFormatFiat';
+import { TELLER_ABI } from '../utils/moneyAccountTransactions';
+import useMoneyToasts from './useMoneyToasts';
+
+const TELLER_INTERFACE = new ethers.utils.Interface(TELLER_ABI);
+
+function decodeTellerAmount(
+ type: TransactionType,
+ data: string | undefined,
+): bigint | undefined {
+ if (!data) return undefined;
+ try {
+ if (type === TransactionType.moneyAccountDeposit) {
+ const decoded = TELLER_INTERFACE.decodeFunctionData('deposit', data);
+ return BigInt(decoded[1].toString());
+ }
+ if (type === TransactionType.moneyAccountWithdraw) {
+ const decoded = TELLER_INTERFACE.decodeFunctionData('withdraw', data);
+ return BigInt(decoded[1].toString());
+ }
+ } catch (error) {
+ Logger.error(
+ error as Error,
+ 'useMoneyTransactionStatus: failed to decode teller calldata',
+ );
+ }
+ return undefined;
+}
+
+function getMusdFiatRate(): BigNumber | undefined {
+ const state = store.getState();
+ const tokenMarketData = selectTokenMarketData(state);
+ const currencyRates = selectCurrencyRates(state);
+ const networkConfigurations = selectNetworkConfigurations(state);
+
+ const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET];
+ if (!musdAddress) return undefined;
+
+ const checksumAddress = toChecksumAddress(musdAddress);
+ const chainConfig = networkConfigurations?.[CHAIN_IDS.MAINNET];
+ const nativeCurrency = chainConfig?.nativeCurrency;
+ const conversionRate = nativeCurrency
+ ? currencyRates?.[nativeCurrency]?.conversionRate
+ : undefined;
+
+ const priceInNativeCurrency =
+ tokenMarketData?.[CHAIN_IDS.MAINNET]?.[checksumAddress]?.price ??
+ tokenMarketData?.[CHAIN_IDS.MAINNET]?.[musdAddress]?.price;
+
+ if (!conversionRate || priceInNativeCurrency === undefined) return undefined;
+ return new BigNumber(priceInNativeCurrency).times(conversionRate);
+}
+
+export function formatMusdAmountForToast(amountWei: bigint): string {
+ const musdDecimal = new BigNumber(
+ fromTokenMinimalUnitString(amountWei.toString(), MUSD_DECIMALS),
+ );
+ const rate = getMusdFiatRate();
+ const currentCurrency = selectCurrentCurrency(store.getState());
+
+ if (!rate || !currentCurrency) {
+ return `${musdDecimal.toFixed(2)} mUSD`;
+ }
+ return moneyFormatFiat(musdDecimal.times(rate), currentCurrency);
+}
+
+const IN_PROGRESS_KEY = 'in-progress';
+const FAILED_KEY = 'failed';
+const CONFIRMED_KEY = 'confirmed';
+export const IN_PROGRESS_DELAY_MS = 1500;
+
+export const useMoneyTransactionStatus = () => {
+ const { showToast, MoneyToastOptions } = useMoneyToasts();
+ const shownToastsRef = useRef>(new Set());
+ const pendingInProgressRef = useRef<
+ Map>
+ >(new Map());
+ const pendingCleanupsRef = useRef>>(
+ new Set(),
+ );
+
+ useEffect(() => {
+ const pendingInProgress = pendingInProgressRef.current;
+ const pendingCleanups = pendingCleanupsRef.current;
+
+ const cancelPendingInProgress = (transactionId: string) => {
+ const timeoutId = pendingInProgress.get(transactionId);
+ if (timeoutId !== undefined) {
+ clearTimeout(timeoutId);
+ pendingInProgress.delete(transactionId);
+ }
+ };
+
+ const scheduleCleanup = (transactionId: string, finalKey: string) => {
+ const timeoutId = setTimeout(() => {
+ pendingCleanups.delete(timeoutId);
+ shownToastsRef.current.delete(`${transactionId}-${IN_PROGRESS_KEY}`);
+ shownToastsRef.current.delete(`${transactionId}-${finalKey}`);
+ }, TOAST_TRACKING_CLEANUP_DELAY_MS);
+ pendingCleanups.add(timeoutId);
+ };
+
+ const nestedTxWithType = (
+ transactionMeta: TransactionMeta,
+ targetType: TransactionType,
+ ) =>
+ transactionMeta.nestedTransactions?.find(
+ (nested) => nested.type === targetType,
+ );
+
+ const isMoneyDepositTx = (transactionMeta: TransactionMeta) =>
+ transactionMeta.type === TransactionType.moneyAccountDeposit ||
+ Boolean(
+ nestedTxWithType(transactionMeta, TransactionType.moneyAccountDeposit),
+ );
+
+ const isMoneyWithdrawTx = (transactionMeta: TransactionMeta) =>
+ transactionMeta.type === TransactionType.moneyAccountWithdraw ||
+ Boolean(
+ nestedTxWithType(transactionMeta, TransactionType.moneyAccountWithdraw),
+ );
+
+ const isMoneyAccountTx = (transactionMeta: TransactionMeta) =>
+ isMoneyDepositTx(transactionMeta) || isMoneyWithdrawTx(transactionMeta);
+
+ const reserveToastKey = (transactionId: string, key: string) => {
+ const toastKey = `${transactionId}-${key}`;
+ if (shownToastsRef.current.has(toastKey)) return undefined;
+ shownToastsRef.current.add(toastKey);
+ return toastKey;
+ };
+
+ const showInProgressFor = (transactionMeta: TransactionMeta) => {
+ if (!isMoneyAccountTx(transactionMeta)) return;
+ if (!reserveToastKey(transactionMeta.id, IN_PROGRESS_KEY)) return;
+ if (pendingInProgress.has(transactionMeta.id)) return;
+ const timeoutId = setTimeout(() => {
+ pendingInProgress.delete(transactionMeta.id);
+ if (isMoneyDepositTx(transactionMeta)) {
+ showToast(MoneyToastOptions.deposit.inProgress());
+ } else {
+ showToast(MoneyToastOptions.withdraw.inProgress());
+ }
+ }, IN_PROGRESS_DELAY_MS);
+ pendingInProgress.set(transactionMeta.id, timeoutId);
+ };
+
+ const showFailedFor = (transactionMeta: TransactionMeta) => {
+ if (!isMoneyAccountTx(transactionMeta)) return;
+ cancelPendingInProgress(transactionMeta.id);
+ if (!reserveToastKey(transactionMeta.id, FAILED_KEY)) return;
+ if (isMoneyDepositTx(transactionMeta)) {
+ showToast(MoneyToastOptions.deposit.failed());
+ } else {
+ showToast(MoneyToastOptions.withdraw.failed());
+ }
+ scheduleCleanup(transactionMeta.id, FAILED_KEY);
+ };
+
+ const showConfirmedFor = (transactionMeta: TransactionMeta) => {
+ if (!isMoneyAccountTx(transactionMeta)) return;
+ cancelPendingInProgress(transactionMeta.id);
+ if (!reserveToastKey(transactionMeta.id, CONFIRMED_KEY)) return;
+
+ const depositNested = nestedTxWithType(
+ transactionMeta,
+ TransactionType.moneyAccountDeposit,
+ );
+ const withdrawNested = nestedTxWithType(
+ transactionMeta,
+ TransactionType.moneyAccountWithdraw,
+ );
+ const nestedMatch = depositNested ?? withdrawNested;
+ const decodeType =
+ nestedMatch?.type ?? (transactionMeta.type as TransactionType);
+ const decodeData =
+ nestedMatch?.data ??
+ (transactionMeta.txParams?.data as string | undefined);
+
+ const amountBaseUnit = decodeTellerAmount(decodeType, decodeData);
+ const amountFiat =
+ amountBaseUnit !== undefined
+ ? formatMusdAmountForToast(amountBaseUnit)
+ : undefined;
+
+ if (isMoneyDepositTx(transactionMeta)) {
+ showToast(MoneyToastOptions.deposit.success({ amountFiat }));
+ } else {
+ // TODO: derive destination from tx metadata once Perps/Predict transfers ship.
+ showToast(
+ MoneyToastOptions.withdraw.success({
+ amountFiat,
+ destination: strings('money.transfer_sheet.between_accounts'),
+ }),
+ );
+ }
+ scheduleCleanup(transactionMeta.id, CONFIRMED_KEY);
+ };
+
+ const handleTransactionStatusUpdated = ({
+ transactionMeta,
+ }: {
+ transactionMeta: TransactionMeta;
+ }) => {
+ switch (transactionMeta.status) {
+ case TransactionStatus.approved:
+ showInProgressFor(transactionMeta);
+ break;
+ case TransactionStatus.failed:
+ case TransactionStatus.dropped:
+ case TransactionStatus.rejected:
+ case TransactionStatus.cancelled:
+ showFailedFor(transactionMeta);
+ break;
+ default:
+ break;
+ }
+ };
+
+ const handleTransactionConfirmed = (transactionMeta: TransactionMeta) => {
+ if (transactionMeta.status !== TransactionStatus.confirmed) return;
+ showConfirmedFor(transactionMeta);
+ };
+
+ Engine.controllerMessenger.subscribe(
+ 'TransactionController:transactionStatusUpdated',
+ handleTransactionStatusUpdated,
+ );
+ Engine.controllerMessenger.subscribe(
+ 'TransactionController:transactionConfirmed',
+ handleTransactionConfirmed,
+ );
+
+ return () => {
+ Engine.controllerMessenger.unsubscribe(
+ 'TransactionController:transactionStatusUpdated',
+ handleTransactionStatusUpdated,
+ );
+ Engine.controllerMessenger.unsubscribe(
+ 'TransactionController:transactionConfirmed',
+ handleTransactionConfirmed,
+ );
+ pendingInProgress.forEach((timeoutId) => clearTimeout(timeoutId));
+ pendingInProgress.clear();
+ pendingCleanups.forEach((timeoutId) => clearTimeout(timeoutId));
+ pendingCleanups.clear();
+ };
+ }, [MoneyToastOptions.deposit, MoneyToastOptions.withdraw, showToast]);
+};
diff --git a/app/components/UI/Money/utils/moneyAccountTransactions.ts b/app/components/UI/Money/utils/moneyAccountTransactions.ts
index 0e2c55490953..c8f4bcb2a577 100644
--- a/app/components/UI/Money/utils/moneyAccountTransactions.ts
+++ b/app/components/UI/Money/utils/moneyAccountTransactions.ts
@@ -23,7 +23,7 @@ const LENS_ABI = [
'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)',
];
-const TELLER_ABI = [
+export const TELLER_ABI = [
'function deposit(address depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) payable returns (uint256 shares)',
'function withdraw(address withdrawAsset, uint256 shareAmount, uint256 minimumAssets, address to) returns (uint256 assetsOut)',
];
diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js
index 9e28e4713d8b..2a45aaa40155 100644
--- a/app/core/NotificationManager.js
+++ b/app/core/NotificationManager.js
@@ -23,6 +23,8 @@ import { hasTransactionType } from '../components/Views/confirmations/utils/tran
import TransactionTypes from './TransactionTypes';
export const SKIP_NOTIFICATION_TRANSACTION_TYPES = [
+ TransactionType.moneyAccountDeposit,
+ TransactionType.moneyAccountWithdraw,
TransactionType.musdClaim,
TransactionType.musdConversion,
TransactionType.perpsDeposit,
diff --git a/locales/languages/en.json b/locales/languages/en.json
index f7b22a73522e..4dbaa0ab7baa 100644
--- a/locales/languages/en.json
+++ b/locales/languages/en.json
@@ -6862,6 +6862,19 @@
"send_external": "Send to external address",
"withdraw_to_bank": "Withdraw to bank"
},
+ "toasts": {
+ "in_progress_title": "Transaction in progress",
+ "in_progress_body": "This may take a few minutes.",
+ "success_title": "Transaction complete",
+ "deposit_success_body": "{{amount}} added to Money account.",
+ "deposit_success_body_no_amount": "Added to Money account.",
+ "withdraw_success_body": "{{amount}} moved to {{destination}}.",
+ "withdraw_success_body_no_amount": "Moved to {{destination}}.",
+ "deposit_failed_title": "Transaction failed",
+ "deposit_failed_body": "Unable to add funds. Try again.",
+ "withdraw_failed_title": "Transfer failed",
+ "withdraw_failed_body": "Unable to transfer funds. Try again."
+ },
"apy_tooltip": {
"title": "Annual Percentage Yield (APY)",
"paragraph_1": "Your Money account earns up to {{percentage}}% automatically.",
From af1f61f4ecef3027298f0d516c6dea954aa7f279 Mon Sep 17 00:00:00 2001
From: jvbriones <1674192+jvbriones@users.noreply.github.com>
Date: Mon, 25 May 2026 17:01:43 +0200
Subject: [PATCH 6/6] ci: re-run CI on finished runs when e2e labels are
modified in the PR (#30580)
## **Description**
Re-run CI on finished runs when e2e labels are modified in the PR
## **Changelog**
CHANGELOG entry:
## **Related issues**
Fixes:
## **Manual testing steps**
```gherkin
Feature: my feature name
Scenario: user [verb for user action]
Given [describe expected initial app state]
When user [verb for user action]
Then [describe expected outcome]
```
## **Screenshots/Recordings**
### **Before**
### **After**
## **Pre-merge author checklist**
- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile
Coding
Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I've included tests if applicable
- [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I've applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.
#### Performance checks (if applicable)
- [ ] I've tested on Android
- Ideally on a mid-range device; emulator is acceptable
- [ ] I've tested with a power user scenario
- Use these [power-user
SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93)
to import wallets with many accounts and tokens
- [ ] I've instrumented key operations with Sentry traces for production
performance metrics
- See [`trace()`](/app/util/trace.ts) for usage and
[`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274)
for an example
For performance guidelines and tooling, see the [Performance
Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers).
## **Pre-merge reviewer checklist**
- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
---
> [!NOTE]
> **Low Risk**
> Changes only GitHub Actions orchestration for label-triggered CI
reruns; no app, auth, or data paths.
>
> **Overview**
> Tightens the **rerun CI on E2E skip label** workflow so cancellation
and rerun behave correctly when multiple `ci.yml` runs exist on the
branch.
>
> The **wait-for-cancel** step no longer tracks a single run ID from the
find step. It polls `gh run list` for any `ci.yml` runs on the head
branch that are still `in_progress` or `queued`, and only proceeds when
that count hits zero (or times out after 600s).
>
> The **rerun** step now runs only when the pull request is **open** and
a latest run ID was found. Rerun is invoked directly without swallowing
failures, so a non-retriable run surfaces as a workflow error instead of
a soft log message.
>
> Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
cc822244c485319f1be12ff3fdff9d32d0a7c1d5. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).
---
.../rerun-ci-on-skipped-e2e-labels.yml | 36 ++++++++++---------
1 file changed, 19 insertions(+), 17 deletions(-)
diff --git a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml
index 1ada12ea5f60..6793ca19fb08 100644
--- a/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml
+++ b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml
@@ -61,20 +61,26 @@ jobs:
echo "run_id=$LATEST_RUN_ID" >> "$GITHUB_OUTPUT"
- name: Wait for cancellation to complete
- if: steps.cancel.outputs.cancelled == 'true' && steps.find.outputs.run_id
+ if: steps.cancel.outputs.cancelled == 'true'
run: |
- RUN_ID="${{ steps.find.outputs.run_id }}"
MAX_WAIT=600
ELAPSED=0
+ IN_PROGRESS=1
- echo "Waiting up to ${MAX_WAIT}s for workflow to finish cancelling..."
+ echo "Waiting up to ${MAX_WAIT}s for all CI runs to finish cancelling..."
while [ $ELAPSED -lt $MAX_WAIT ]; do
- STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status')
- echo "Status: $STATUS (${ELAPSED}s elapsed)"
+ IN_PROGRESS=$(gh run list \
+ --repo "$REPO" \
+ --branch "$HEAD_REF" \
+ --workflow "ci.yml" \
+ --json databaseId,status \
+ --jq '[.[] | select(.status == "in_progress" or .status == "queued")] | length')
+
+ echo "In-progress/queued runs: $IN_PROGRESS (${ELAPSED}s elapsed)"
- if [ "$STATUS" != "in_progress" ] && [ "$STATUS" != "queued" ]; then
- echo "Workflow ready for rerun"
+ if [ "$IN_PROGRESS" -eq 0 ]; then
+ echo "No active runs remaining — ready to rerun"
break
fi
@@ -82,19 +88,15 @@ jobs:
ELAPSED=$((ELAPSED + 15))
done
- FINAL_STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status')
- if [ "$FINAL_STATUS" = "in_progress" ] || [ "$FINAL_STATUS" = "queued" ]; then
- echo "Timeout: workflow still $FINAL_STATUS after ${MAX_WAIT}s"
+ if [ "$IN_PROGRESS" -gt 0 ]; then
+ echo "Timeout: $IN_PROGRESS run(s) still active after ${MAX_WAIT}s"
exit 1
fi
- name: Rerun CI workflow
- if: steps.find.outputs.run_id
+ if: github.event.pull_request.state == 'open' && steps.find.outputs.run_id
run: |
RUN_ID="${{ steps.find.outputs.run_id }}"
- echo "Re-running workflow $RUN_ID..."
- if gh run rerun "$RUN_ID" --repo "$REPO"; then
- echo "CI workflow re-triggered successfully"
- else
- echo "Rerun not possible (run may not be in a retriable state)"
- fi
+ echo "Re-running CI workflow run $RUN_ID..."
+ gh run rerun "$RUN_ID" --repo "$REPO"
+ echo "CI workflow re-triggered successfully"