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** image ## **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"