diff --git a/.github/workflows/build-android-e2e.yml b/.github/workflows/build-android-e2e.yml index d3eb7d0bfdb..85c260aba4e 100644 --- a/.github/workflows/build-android-e2e.yml +++ b/.github/workflows/build-android-e2e.yml @@ -32,7 +32,7 @@ on: jobs: build-android-apks: name: Build Android E2E APKs - runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-lg + runs-on: ghcr.io/cirruslabs/ubuntu-runner-amd64:24.04-xl # Bumped from lg to xl to prevent Daemon disappearance issue (Daemon OOM issue in CI) timeout-minutes: 40 env: GRADLE_USER_HOME: /home/admin/_work/.gradle diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c98639dc692..8d1f88c2542 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -225,7 +225,7 @@ jobs: echo "No changes detected" fi needs_e2e_build: - uses: ./.github/workflows/needs-e2e-build.yml + uses: ./.github/workflows/needs-e2e-build.yml component-view-test: runs-on: ubuntu-latest steps: @@ -277,7 +277,7 @@ jobs: post-comment: 'true' # Main E2E tests - + build-android-apks: name: 'Build Android APKs' if: ${{ github.event_name != 'merge_group' && needs.needs_e2e_build.outputs.android_changed == 'true' }} @@ -549,16 +549,24 @@ jobs: # Check E2E jobs only if they should have run if [[ "${{ needs.needs_e2e_build.outputs.builds }}" == "true" ]]; then - if [[ "${{ needs.e2e-smoke-tests-android.result }}" == "failure" ]]; then - echo "Android E2E tests failed" + # Accept both 'success' and 'skipped' as valid results + # 'skipped' occurs during merge_group events or when jobs are intentionally skipped + # Only fail on 'failure' or 'cancelled' + ANDROID_RESULT="${{ needs.e2e-smoke-tests-android.result }}" + if [[ "$ANDROID_RESULT" == "failure" ]] || [[ "$ANDROID_RESULT" == "cancelled" ]]; then + echo "Android E2E tests failed (result: $ANDROID_RESULT)" exit 1 fi - if [[ "${{ needs.e2e-smoke-tests-ios.result }}" == "failure" ]]; then - echo "iOS E2E tests failed" + + IOS_RESULT="${{ needs.e2e-smoke-tests-ios.result }}" + if [[ "$IOS_RESULT" == "failure" ]] || [[ "$IOS_RESULT" == "cancelled" ]]; then + echo "iOS E2E tests failed (result: $IOS_RESULT)" exit 1 fi - if [[ "${{ needs.e2e-smoke-tests-android-flask.result }}" == "failure" ]]; then - echo "Android Flask E2E tests failed" + + FLASK_RESULT="${{ needs.e2e-smoke-tests-android-flask.result }}" + if [[ "$FLASK_RESULT" == "failure" ]] || [[ "$FLASK_RESULT" == "cancelled" ]]; then + echo "Android Flask E2E tests failed (result: $FLASK_RESULT)" exit 1 fi fi diff --git a/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch b/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch deleted file mode 100644 index 2fe51713529..00000000000 --- a/.yarn/patches/@metamask-assets-controllers-npm-89.0.1-02fa7acd54.patch +++ /dev/null @@ -1,61 +0,0 @@ -diff --git a/dist/TokenBalancesController.cjs b/dist/TokenBalancesController.cjs -index 4918812dde60b8d0e24a7bded27d88f233968858..4e8018bce92b9e5d47fc40784409e16db22be615 100644 ---- a/dist/TokenBalancesController.cjs -+++ b/dist/TokenBalancesController.cjs -@@ -535,14 +535,16 @@ class TokenBalancesController extends (0, polling_controller_1.StaticIntervalPol - } - // Update with actual fetched balances only if the value has changed - aggregated.forEach(({ success, value, account, token, chainId }) => { -- var _a, _b, _c; -+ var _a, _b; - if (success && value !== undefined) { -+ // Ensure all accounts we add/update are in lower-case -+ const lowerCaseAccount = account.toLowerCase(); - const newBalance = (0, controller_utils_1.toHex)(value); - const tokenAddress = checksum(token); -- const currentBalance = d.tokenBalances[account]?.[chainId]?.[tokenAddress]; -+ const currentBalance = d.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; - // Only update if the balance has actually changed - if (currentBalance !== newBalance) { -- ((_c = ((_a = d.tokenBalances)[_b = account] ?? (_a[_b] = {})))[chainId] ?? (_c[chainId] = {}))[tokenAddress] = newBalance; -+ ((_b = ((_a = d.tokenBalances)[lowerCaseAccount] ?? (_a[lowerCaseAccount] = {})))[chainId] ?? (_b[chainId] = {}))[tokenAddress] = newBalance; - } - } - }); -diff --git a/dist/TokenBalancesController.mjs b/dist/TokenBalancesController.mjs -index f64d13f8de56631345a44e6ebb025e62e03f51bc..99aa7f27c574c94b26daa56091ac50d15281dd30 100644 ---- a/dist/TokenBalancesController.mjs -+++ b/dist/TokenBalancesController.mjs -@@ -531,14 +531,16 @@ export class TokenBalancesController extends StaticIntervalPollingController() { - } - // Update with actual fetched balances only if the value has changed - aggregated.forEach(({ success, value, account, token, chainId }) => { -- var _a, _b, _c; -+ var _a, _b; - if (success && value !== undefined) { -+ // Ensure all accounts we add/update are in lower-case -+ const lowerCaseAccount = account.toLowerCase(); - const newBalance = toHex(value); - const tokenAddress = checksum(token); -- const currentBalance = d.tokenBalances[account]?.[chainId]?.[tokenAddress]; -+ const currentBalance = d.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; - // Only update if the balance has actually changed - if (currentBalance !== newBalance) { -- ((_c = ((_a = d.tokenBalances)[_b = account] ?? (_a[_b] = {})))[chainId] ?? (_c[chainId] = {}))[tokenAddress] = newBalance; -+ ((_b = ((_a = d.tokenBalances)[lowerCaseAccount] ?? (_a[lowerCaseAccount] = {})))[chainId] ?? (_b[chainId] = {}))[tokenAddress] = newBalance; - } - } - }); -diff --git a/dist/token-prices-service/codefi-v2.cjs b/dist/token-prices-service/codefi-v2.cjs -index 34f7bcf4dea1b8d6a1ea45051be09059d9d35353..6aa82360e63727852cda1719f5e893508b764e75 100644 ---- a/dist/token-prices-service/codefi-v2.cjs -+++ b/dist/token-prices-service/codefi-v2.cjs -@@ -98,6 +98,8 @@ exports.SUPPORTED_CURRENCIES = [ - 'mxn', - // Malaysian Ringgit - 'myr', -+ // Monad -+ 'mon', - // Nigerian Naira - 'ngn', - // Norwegian Krone diff --git a/.yarn/patches/@metamask-assets-controllers-npm-91.0.0-ea998cb0bd.patch b/.yarn/patches/@metamask-assets-controllers-npm-91.0.0-ea998cb0bd.patch new file mode 100644 index 00000000000..f27d4cd8d2e --- /dev/null +++ b/.yarn/patches/@metamask-assets-controllers-npm-91.0.0-ea998cb0bd.patch @@ -0,0 +1,139 @@ +diff --git a/dist/TokenBalancesController.cjs b/dist/TokenBalancesController.cjs +index 4918812dde60b8d0e24a7bded27d88f233968858..4e8018bce92b9e5d47fc40784409e16db22be615 100644 +--- a/dist/TokenBalancesController.cjs ++++ b/dist/TokenBalancesController.cjs +@@ -535,14 +535,16 @@ class TokenBalancesController extends (0, polling_controller_1.StaticIntervalPol + } + // Update with actual fetched balances only if the value has changed + aggregated.forEach(({ success, value, account, token, chainId }) => { +- var _a, _b, _c; ++ var _a, _b; + if (success && value !== undefined) { ++ // Ensure all accounts we add/update are in lower-case ++ const lowerCaseAccount = account.toLowerCase(); + const newBalance = (0, controller_utils_1.toHex)(value); + const tokenAddress = checksum(token); +- const currentBalance = d.tokenBalances[account]?.[chainId]?.[tokenAddress]; ++ const currentBalance = d.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; + // Only update if the balance has actually changed + if (currentBalance !== newBalance) { +- ((_c = ((_a = d.tokenBalances)[_b = account] ?? (_a[_b] = {})))[chainId] ?? (_c[chainId] = {}))[tokenAddress] = newBalance; ++ ((_b = ((_a = d.tokenBalances)[lowerCaseAccount] ?? (_a[lowerCaseAccount] = {})))[chainId] ?? (_b[chainId] = {}))[tokenAddress] = newBalance; + } + } + }); +diff --git a/dist/TokenBalancesController.mjs b/dist/TokenBalancesController.mjs +index f64d13f8de56631345a44e6ebb025e62e03f51bc..99aa7f27c574c94b26daa56091ac50d15281dd30 100644 +--- a/dist/TokenBalancesController.mjs ++++ b/dist/TokenBalancesController.mjs +@@ -531,14 +531,16 @@ export class TokenBalancesController extends StaticIntervalPollingController() { + } + // Update with actual fetched balances only if the value has changed + aggregated.forEach(({ success, value, account, token, chainId }) => { +- var _a, _b, _c; ++ var _a, _b; + if (success && value !== undefined) { ++ // Ensure all accounts we add/update are in lower-case ++ const lowerCaseAccount = account.toLowerCase(); + const newBalance = toHex(value); + const tokenAddress = checksum(token); +- const currentBalance = d.tokenBalances[account]?.[chainId]?.[tokenAddress]; ++ const currentBalance = d.tokenBalances[lowerCaseAccount]?.[chainId]?.[tokenAddress]; + // Only update if the balance has actually changed + if (currentBalance !== newBalance) { +- ((_c = ((_a = d.tokenBalances)[_b = account] ?? (_a[_b] = {})))[chainId] ?? (_c[chainId] = {}))[tokenAddress] = newBalance; ++ ((_b = ((_a = d.tokenBalances)[lowerCaseAccount] ?? (_a[lowerCaseAccount] = {})))[chainId] ?? (_b[chainId] = {}))[tokenAddress] = newBalance; + } + } + }); +diff --git a/dist/token-prices-service/codefi-v2.cjs b/dist/token-prices-service/codefi-v2.cjs +index ba0f0c1bcbf0f231549b1ca9d3be2d1137a0d732..fd4851471fa0c2f07efbb527a3eea55cbfbc4743 100644 +--- a/dist/token-prices-service/codefi-v2.cjs ++++ b/dist/token-prices-service/codefi-v2.cjs +@@ -100,6 +100,8 @@ exports.SUPPORTED_CURRENCIES = [ + 'mxn', + // Malaysian Ringgit + 'myr', ++ // Monad ++ 'mon', + // Nigerian Naira + 'ngn', + // Norwegian Krone +@@ -220,43 +222,43 @@ exports.getNativeTokenAddress = getNativeTokenAddress; + // Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts + // We can only support PricesAPI V3 for EVM chains that have a CAIP-19 native asset mapping. + exports.SPOT_PRICES_SUPPORT_INFO = { +- '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH +- '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH +- '0x19': 'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO +- '0x38': 'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB +- '0x39': 'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS ++ '0x1': null, //'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH ++ '0xa': null, //'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH ++ '0x19': null, //'eip155:25/slip44:394', // Cronos Mainnet - Native symbol: CRO ++ '0x38': null, //'eip155:56/slip44:714', // BNB Smart Chain Mainnet - Native symbol: BNB ++ '0x39': null, //'eip155:57/erc20:0x0000000000000000000000000000000000000000', // 'eip155:57/slip44:57', // Syscoin Mainnet - Native symbol: SYS + '0x52': null, // 'eip155:82/slip44:18000', // Meter Mainnet - Native symbol: MTR +- '0x58': 'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO +- '0x64': 'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI +- '0x6a': 'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX +- '0x80': 'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT +- '0x89': 'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL ++ '0x58': null, //'eip155:88/erc20:0x0000000000000000000000000000000000000000', // 'eip155:88/slip44:889', // TomoChain - Native symbol: TOMO ++ '0x64': null, //'eip155:100/slip44:700', // Gnosis (formerly xDAI Chain) - Native symbol: xDAI ++ '0x6a': null, //'eip155:106/erc20:0x0000000000000000000000000000000000000000', // 'eip155:106/slip44:5655640', // Velas EVM Mainnet - Native symbol: VLX ++ '0x80': null, //'eip155:128/erc20:0x0000000000000000000000000000000000000000', // 'eip155:128/slip44:1010', // Huobi ECO Chain Mainnet - Native symbol: HT ++ '0x89': null, //'eip155:137/slip44:966', // Polygon Mainnet - Native symbol: POL + '0x8f': null, // 'eip155:143/slip44:268435779', // Monad Mainnet - Native symbol: MON +- '0x92': 'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S +- '0xfa': 'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM +- '0x141': 'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS +- '0x144': 'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH +- '0x169': 'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL +- '0x3e7': 'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH +- '0x440': 'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS +- '0x44d': 'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH +- '0x504': 'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR +- '0x505': 'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR +- '0x531': 'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI +- '0x1388': 'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT +- '0x2105': 'eip155:8453/slip44:60', // Base - Native symbol: ETH +- '0x2710': 'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH +- '0xa4b1': 'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH +- '0xa4ec': 'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO +- '0xa516': 'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE +- '0xa86a': 'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX +- '0xe708': 'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH +- '0x13c31': 'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH +- '0x17dcd': 'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU ++ '0x92': null, //'eip155:146/slip44:10007', // Sonic Mainnet - Native symbol: S ++ '0xfa': null, //'eip155:250/slip44:1007', // Fantom Opera - Native symbol: FTM ++ '0x141': null, //'eip155:321/erc20:0x0000000000000000000000000000000000000000', // 'eip155:321/slip44:641', // KCC Mainnet - Native symbol: KCS ++ '0x144': null, //'eip155:324/slip44:60', // zkSync Era Mainnet (Ethereum L2) - Native symbol: ETH ++ '0x169': null, //'eip155:361/erc20:0x0000000000000000000000000000000000000000', // 'eip155:361/slip44:589', // Theta Mainnet - Native symbol: TFUEL ++ '0x3e7': null, //'eip155:999/slip44:2457', // HyperEVM - Native symbol: ETH ++ '0x440': null, //'eip155:1088/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:1088/slip44:XXX', // Metis Andromeda Mainnet (Ethereum L2) - Native symbol: METIS ++ '0x44d': null, //'eip155:1101/slip44:60', // Polygon zkEVM mainnet - Native symbol: ETH ++ '0x504': null, //'eip155:1284/slip44:1284', // Moonbeam - Native symbol: GLMR ++ '0x505': null, //'eip155:1285/slip44:1285', // Moonriver - Native symbol: MOVR ++ '0x531': null, //'eip155:1329/slip44:19000118', // Sei Mainnet - Native symbol: SEI ++ '0x1388': null, //'eip155:5000/erc20:0xdeaddeaddeaddeaddeaddeaddeaddeaddead0000', // 'eip155:5000/slip44:XXX', // Mantle - Native symbol: MNT ++ '0x2105': null, //'eip155:8453/slip44:60', // Base - Native symbol: ETH ++ '0x2710': null, //'eip155:10000/erc20:0x0000000000000000000000000000000000000000', // 'eip155:10000/slip44:145', // Smart Bitcoin Cash - Native symbol: BCH ++ '0xa4b1': null, //'eip155:42161/slip44:60', // Arbitrum One - Native symbol: ETH ++ '0xa4ec': null, //'eip155:42220/slip44:52752', // Celo Mainnet - Native symbol: CELO ++ '0xa516': null, //'eip155:42262/erc20:0x0000000000000000000000000000000000000000', // 'eip155:42262/slip44:474', // Oasis Emerald - Native symbol: ROSE ++ '0xa86a': null, //'eip155:43114/slip44:9005', // Avalanche C-Chain - Native symbol: AVAX ++ '0xe708': null, //'eip155:59144/slip44:60', // Linea Mainnet - Native symbol: ETH ++ '0x13c31': null, //'eip155:81457/erc20:0x0000000000000000000000000000000000000000', // 'eip155:81457/slip44:60', // Blast Mainnet - Native symbol: ETH ++ '0x17dcd': null, //'eip155:97741/erc20:0x0000000000000000000000000000000000000000', // 'eip155:97741/slip44:XXX', // Pepe Unchained Mainnet - Native symbol: PEPU + '0x518af': null, // 'eip155:333999/slip44:1997', // Polis Mainnet - Native symbol: POLIS +- '0x82750': 'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH +- '0x4e454152': 'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH +- '0x63564c40': 'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE ++ '0x82750': null, //'eip155:534352/slip44:60', // Scroll Mainnet - Native symbol: ETH ++ '0x4e454152': null, //'eip155:60/slip44:60', // Aurora Mainnet (Ethereum L2 on NEAR) - Native symbol: ETH ++ '0x63564c40': null, //'eip155:1666600000/slip44:1023', // Harmony Mainnet Shard 0 - Native symbol: ONE + }; + /** + * The list of chain IDs that can be supplied in the URL for the `/spot-prices` diff --git a/app/actions/user/index.ts b/app/actions/user/index.ts index 5620eda34c5..3655e170f10 100644 --- a/app/actions/user/index.ts +++ b/app/actions/user/index.ts @@ -25,6 +25,7 @@ import { type SetExistingUserAction, type SetIsConnectionRemovedAction, type SetMultichainAccountsIntroModalSeenAction, + type SetMusdConversionEducationSeenAction, UserActionType, } from './types'; @@ -213,3 +214,12 @@ export function setMultichainAccountsIntroModalSeen( payload: { seen }, }; } + +/** + * Action to set mUSD conversion education as seen + */ +export function setMusdConversionEducationSeen(): SetMusdConversionEducationSeenAction { + return { + type: UserActionType.SET_MUSD_CONVERSION_EDUCATION_SEEN, + }; +} diff --git a/app/actions/user/types.ts b/app/actions/user/types.ts index 4af31fa01b6..b16da73ca40 100644 --- a/app/actions/user/types.ts +++ b/app/actions/user/types.ts @@ -28,6 +28,7 @@ export enum UserActionType { SET_EXISTING_USER = 'SET_EXISTING_USER', SET_IS_CONNECTION_REMOVED = 'SET_IS_CONNECTION_REMOVED', SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN = 'SET_MULTICHAIN_ACCOUNTS_INTRO_MODAL_SEEN', + SET_MUSD_CONVERSION_EDUCATION_SEEN = 'SET_MUSD_CONVERSION_EDUCATION_SEEN', } // User actions @@ -109,6 +110,9 @@ export type SetMultichainAccountsIntroModalSeenAction = payload: { seen: boolean }; }; +export type SetMusdConversionEducationSeenAction = + Action; + /** * User actions union type */ @@ -137,4 +141,5 @@ export type UserAction = | SetAppServicesReadyAction | SetExistingUserAction | SetIsConnectionRemovedAction - | SetMultichainAccountsIntroModalSeenAction; + | SetMultichainAccountsIntroModalSeenAction + | SetMusdConversionEducationSeenAction; diff --git a/app/components/Nav/Main/RootRPCMethodsUI.js b/app/components/Nav/Main/RootRPCMethodsUI.js index 89cdcd70411..964ec2a0e63 100644 --- a/app/components/Nav/Main/RootRPCMethodsUI.js +++ b/app/components/Nav/Main/RootRPCMethodsUI.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Alert } from 'react-native'; import PropTypes from 'prop-types'; @@ -28,16 +28,9 @@ import { import BN from 'bnjs4'; import Logger from '../../../util/Logger'; import TransactionTypes from '../../../core/TransactionTypes'; -import { swapsUtils } from '@metamask/swaps-controller'; -import { query } from '@metamask/controller-utils'; -import BigNumber from 'bignumber.js'; import { KEYSTONE_TX_CANCELED } from '../../../constants/error'; import { MetaMetricsEvents } from '../../../core/Analytics'; -import { - getAddressAccountType, - isHardwareAccount, - areAddressesEqual, -} from '../../../util/address'; +import { isHardwareAccount, areAddressesEqual } from '../../../util/address'; import { selectEvmChainId, @@ -59,21 +52,15 @@ import TemplateConfirmationModal from '../../Approvals/TemplateConfirmationModal import { selectTokenList } from '../../../selectors/tokenListController'; import { selectTokens } from '../../../selectors/tokensController'; import { getDeviceId } from '../../../core/Ledger/Ledger'; -import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import { createLedgerTransactionModalNavDetails } from '../../UI/LedgerModals/LedgerTransactionModal'; import ExtendedKeyringTypes from '../../../constants/keyringTypes'; import { ConfirmRoot } from '../../../components/Views/confirmations/components/confirm'; import { useMetrics } from '../../../components/hooks/useMetrics'; -import { selectShouldUseSmartTransaction } from '../../../selectors/smartTransactionsController'; import { STX_NO_HASH_ERROR } from '../../../util/smart-transactions/smart-publish-hook'; -import { getSmartTransactionMetricsProperties } from '../../../util/smart-transactions'; -import { cloneDeep, isEqual } from 'lodash'; -import { selectSwapsTransactions } from '../../../selectors/transactionController'; -import { updateSwapsTransaction } from '../../../util/swaps/swaps-transactions'; +import { cloneDeep } from 'lodash'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import InstallSnapApproval from '../../Approvals/InstallSnapApproval'; -import { getGlobalEthQuery } from '../../../util/networks/global-network'; import SnapDialogApproval from '../../Snaps/SnapDialogApproval/SnapDialogApproval'; ///: END:ONLY_INCLUDE_IF ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) @@ -83,56 +70,6 @@ import { getIsBridgeTransaction } from '../../UI/Bridge/utils/transaction'; const hstInterface = new ethers.utils.Interface(abi); -function useSwapsTransactions() { - const swapTransactions = useSelector(selectSwapsTransactions, isEqual); - - // Memo prevents fresh fallback empty object on every render. - return useMemo(() => swapTransactions ?? {}, [swapTransactions]); -} - -export const useSwapConfirmedEvent = ({ trackSwaps }) => { - const [transactionMetaIdsForListening, setTransactionMetaIdsForListening] = - useState([]); - - const addTransactionMetaIdForListening = useCallback((txMetaId) => { - setTransactionMetaIdsForListening((transactionMetaIdsForListening) => [ - ...transactionMetaIdsForListening, - txMetaId, - ]); - }, []); - const swapsTransactions = useSwapsTransactions(); - - useEffect(() => { - // Cannot directly call trackSwaps from the event listener in autoSign due to stale closure of swapsTransactions - const [txMetaId, ...restTxMetaIds] = transactionMetaIdsForListening; - - if (txMetaId && swapsTransactions[txMetaId]) { - Engine.controllerMessenger.subscribeOnceIf( - 'TransactionController:transactionConfirmed', - (transactionMeta) => { - if ( - swapsTransactions[transactionMeta.id]?.analytics && - swapsTransactions[transactionMeta.id]?.paramsForAnalytics - ) { - trackSwaps( - MetaMetricsEvents.SWAP_COMPLETED, - transactionMeta, - swapsTransactions, - ); - } - }, - (transactionMeta) => transactionMeta.id === txMetaId, - ); - setTransactionMetaIdsForListening(restTxMetaIds); - } - }, [trackSwaps, transactionMetaIdsForListening, swapsTransactions]); - - return { - addTransactionMetaIdForListening, - transactionMetaIdsForListening, - }; -}; - const RootRPCMethodsUI = (props) => { const { trackEvent, createEventBuilder } = useMetrics(); const [transactionModalType, setTransactionModalType] = useState(undefined); @@ -144,156 +81,8 @@ const RootRPCMethodsUI = (props) => { WalletConnect.init(); }; - const trackSwaps = useCallback( - async (event, transactionMeta, swapsTransactions) => { - try { - const { TransactionController, SmartTransactionsController } = - Engine.context; - const swapTransaction = swapsTransactions[transactionMeta.id]; - - const { - sentAt, - gasEstimate, - ethAccountBalance, - approvalTransactionMetaId, - } = swapTransaction.paramsForAnalytics; - - const approvalTransaction = - TransactionController.state.transactions.find( - ({ id }) => id === approvalTransactionMetaId, - ); - - const ethQuery = getGlobalEthQuery(); - - const ethBalance = await query(ethQuery, 'getBalance', [ - props.selectedAddress, - ]); - const receipt = await query(ethQuery, 'getTransactionReceipt', [ - transactionMeta.hash, - ]); - - const currentBlock = await query(ethQuery, 'getBlockByHash', [ - receipt.blockHash, - false, - ]); - let approvalReceipt; - if (approvalTransaction?.hash) { - approvalReceipt = await query(ethQuery, 'getTransactionReceipt', [ - approvalTransaction.hash, - ]); - } - const tokensReceived = swapsUtils.getSwapsTokensReceived( - receipt, - approvalReceipt, - transactionMeta?.txParams, - approvalTransaction?.txParams, - swapTransaction.destinationToken, - ethAccountBalance, - ethBalance, - ); - - const timeToMine = currentBlock.timestamp - sentAt; - const estimatedVsUsedGasRatio = `${new BigNumber(receipt.gasUsed) - .div(gasEstimate) - .times(100) - .toFixed(2)}%`; - const quoteVsExecutionRatio = `${swapsUtils - .calcTokenAmount( - tokensReceived || '0x0', - swapTransaction.destinationTokenDecimals, - ) - .div(swapTransaction.destinationAmount) - .times(100) - .toFixed(2)}%`; - const tokenToAmountReceived = swapsUtils.calcTokenAmount( - tokensReceived, - swapTransaction.destinationToken.decimals, - ); - - const analyticsParams = { - ...swapTransaction.analytics, - account_type: getAddressAccountType(transactionMeta.txParams.from), - }; - - updateSwapsTransaction(transactionMeta.id, (swapsTransaction) => { - swapsTransaction.gasUsed = receipt.gasUsed; - - if (tokensReceived) { - swapsTransaction.receivedDestinationAmount = new BigNumber( - tokensReceived, - 16, - ).toString(10); - } - - delete swapsTransaction.analytics; - delete swapsTransaction.paramsForAnalytics; - }); - - const smartTransactionMetricsProperties = - getSmartTransactionMetricsProperties( - SmartTransactionsController, - transactionMeta, - ); - - const parameters = { - time_to_mine: timeToMine, - estimated_vs_used_gasRatio: estimatedVsUsedGasRatio, - quote_vs_executionRatio: quoteVsExecutionRatio, - token_to_amount_received: tokenToAmountReceived.toString(), - is_smart_transaction: props.shouldUseSmartTransaction, - gas_included: analyticsParams.isGasIncludedTrade, - ...smartTransactionMetricsProperties, - available_quotes: analyticsParams.available_quotes, - best_quote_source: analyticsParams.best_quote_source, - chain_id: analyticsParams.chain_id, - custom_slippage: analyticsParams.custom_slippage, - network_fees_USD: analyticsParams.network_fees_USD, - other_quote_selected: analyticsParams.other_quote_selected, - request_type: analyticsParams.request_type, - token_from: analyticsParams.token_from, - token_to: analyticsParams.token_to, - }; - const sensitiveParameters = { - token_from_amount: analyticsParams.token_from_amount, - token_to_amount: analyticsParams.token_to_amount, - network_fees_ETH: analyticsParams.network_fees_ETH, - }; - - Logger.log('Swaps', 'Sending metrics event', event); - - trackEvent( - createEventBuilder(event) - .addProperties({ ...parameters }) - .addSensitiveProperties({ ...sensitiveParameters }) - .build(), - ); - } catch (e) { - Logger.error(e, MetaMetricsEvents.SWAP_TRACKING_FAILED); - trackEvent( - createEventBuilder(MetaMetricsEvents.SWAP_TRACKING_FAILED) - .addProperties({ - error: e, - }) - .build(), - ); - } - }, - [ - props.selectedAddress, - props.shouldUseSmartTransaction, - trackEvent, - createEventBuilder, - ], - ); - - const { addTransactionMetaIdForListening } = useSwapConfirmedEvent({ - trackSwaps, - }); - const swapsTransactions = useSwapsTransactions(); - const autoSign = useCallback( async (transactionMeta) => { - const { KeyringController } = Engine.context; const { id: transactionId } = transactionMeta; try { @@ -307,13 +96,6 @@ const RootRPCMethodsUI = (props) => { assetType: transactionMeta.txParams.assetType, }); } else { - if (swapsTransactions[transactionMeta.id]?.analytics) { - trackSwaps( - MetaMetricsEvents.SWAP_FAILED, - transactionMeta, - swapsTransactions, - ); - } throw transactionMeta.error; } } catch (error) { @@ -323,9 +105,6 @@ const RootRPCMethodsUI = (props) => { (transactionMeta) => transactionMeta.id === transactionId, ); - // Queue txMetaId to listen for confirmation event - addTransactionMetaIdForListening(transactionMeta.id); - const isLedgerAccount = isHardwareAccount( transactionMeta.txParams.from, [ExtendedKeyringTypes.ledger], @@ -372,14 +151,7 @@ const RootRPCMethodsUI = (props) => { } } }, - [ - props.navigation, - trackSwaps, - trackEvent, - swapsTransactions, - addTransactionMetaIdForListening, - createEventBuilder, - ], + [props.navigation, trackEvent, createEventBuilder], ); const onUnapprovedTransaction = useCallback( @@ -578,29 +350,16 @@ RootRPCMethodsUI.propTypes = { * Array of ERC20 assets */ tokens: PropTypes.array, - /** - * Selected address - */ - selectedAddress: PropTypes.string, /** * Chain id */ chainId: PropTypes.string, - /** - * If smart transactions should be used - */ - shouldUseSmartTransaction: PropTypes.bool, }; const mapStateToProps = (state) => ({ - selectedAddress: selectSelectedInternalAccountFormattedAddress(state), chainId: selectEvmChainId(state), tokens: selectTokens(state), providerType: selectProviderType(state), - shouldUseSmartTransaction: selectShouldUseSmartTransaction( - state, - selectEvmChainId(state), - ), }); const mapDispatchToProps = (dispatch) => ({ diff --git a/app/components/Nav/Main/index.test.tsx b/app/components/Nav/Main/index.test.tsx index 9f95a7715f6..6ff38770376 100644 --- a/app/components/Nav/Main/index.test.tsx +++ b/app/components/Nav/Main/index.test.tsx @@ -4,11 +4,6 @@ import { shallow } from 'enzyme'; // eslint-disable-next-line import/named import { NavigationContainer } from '@react-navigation/native'; import Main from './'; -import { useSwapConfirmedEvent } from './RootRPCMethodsUI'; -import { act } from '@testing-library/react-hooks'; -import { MetaMetricsEvents } from '../../hooks/useMetrics'; -import { renderHookWithProvider } from '../../../util/test/renderWithProvider'; -import Engine from '../../../core/Engine'; import configureMockStore from 'redux-mock-store'; import { Provider } from 'react-redux'; @@ -49,84 +44,6 @@ const mockInitialState = { }, }; -jest.mock('../../../core/Engine', () => ({ - controllerMessenger: { - subscribeOnceIf: jest.fn(), - }, -})); - -const TRANSACTION_META_ID_MOCK = '04541dc0-2e69-11ef-b995-33aef2c88d1e'; - -const SWAP_TRANSACTIONS_MOCK = { - [TRANSACTION_META_ID_MOCK]: { - action: 'swap', - analytics: { - available_quotes: 5, - best_quote_source: 'oneInchV5', - chain_id: '1', - custom_slippage: false, - network_fees_ETH: '0.00337', - network_fees_USD: '$12.04', - other_quote_selected: false, - request_type: 'Order', - token_from: 'ETH', - token_from_amount: '0.001254', - token_to: 'USDC', - token_to_amount: '4.440771', - gas_included: false, - }, - destinationAmount: '4440771', - destinationToken: { - address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', - decimals: 6, - }, - paramsForAnalytics: { - approvalTransactionMetaId: {}, - ethAccountBalance: '0xedfffbea734a07', - gasEstimate: '0x33024', - sentAt: '0x66732203', - }, - sourceAmount: '1254000000000000', - sourceAmountInFiat: '$4.47', - sourceToken: { - address: '0x0000000000000000000000000000000000000000', - decimals: 18, - }, - }, -}; - -function renderUseSwapConfirmedEventHook({ - swapsTransactions, - trackSwaps, -}: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - swapsTransactions: any; - trackSwaps?: jest.Func; -}) { - const finalTrackSwaps = trackSwaps || jest.fn(); - - const { result } = renderHookWithProvider( - () => - useSwapConfirmedEvent({ - trackSwaps: finalTrackSwaps, - }), - { - state: { - engine: { - backgroundState: { - TransactionController: { - //@ts-expect-error - swaps transactions is something we do not have implemented on TransacitonController yet - swapsTransactions, - }, - }, - }, - }, - }, - ); - - return result; -} - describe('Main', () => { beforeEach(() => { jest.resetAllMocks(); @@ -161,92 +78,4 @@ describe('Main', () => { const wrapper = shallow(); expect(wrapper).toMatchSnapshot(); }); - - describe('useSwapConfirmedEvent', () => { - it('queues transactionMeta ids correctly', () => { - const result = renderUseSwapConfirmedEventHook({ - swapsTransactions: {}, - }); - - act(() => { - result.current.addTransactionMetaIdForListening( - TRANSACTION_META_ID_MOCK, - ); - }); - - expect(result.current.transactionMetaIdsForListening).toEqual([ - TRANSACTION_META_ID_MOCK, - ]); - }); - - it('adds a listener for transaction confirmation on the TransactionController', () => { - const result = renderUseSwapConfirmedEventHook({ - swapsTransactions: SWAP_TRANSACTIONS_MOCK, - }); - - act(() => { - result.current.addTransactionMetaIdForListening( - TRANSACTION_META_ID_MOCK, - ); - }); - - expect(Engine.controllerMessenger.subscribeOnceIf).toHaveBeenCalledTimes( - 1, - ); - }); - - it('tracks Swap Confirmed after transaction confirmed', () => { - const trackSwaps = jest.fn(); - - const txMeta = { - id: TRANSACTION_META_ID_MOCK, - }; - - const result = renderUseSwapConfirmedEventHook({ - swapsTransactions: SWAP_TRANSACTIONS_MOCK, - trackSwaps, - }); - - act(() => { - result.current.addTransactionMetaIdForListening( - TRANSACTION_META_ID_MOCK, - ); - }); - - jest - .mocked(Engine.controllerMessenger.subscribeOnceIf) - .mock.calls[0][1](txMeta as never); - - expect(trackSwaps).toHaveBeenCalledWith( - MetaMetricsEvents.SWAP_COMPLETED, - txMeta, - SWAP_TRANSACTIONS_MOCK, - ); - }); - - it('removes transactionMeta id after tracking', () => { - const trackSwaps = jest.fn(); - - const txMeta = { - id: TRANSACTION_META_ID_MOCK, - }; - - const result = renderUseSwapConfirmedEventHook({ - swapsTransactions: SWAP_TRANSACTIONS_MOCK, - trackSwaps, - }); - - act(() => { - result.current.addTransactionMetaIdForListening( - TRANSACTION_META_ID_MOCK, - ); - }); - - jest - .mocked(Engine.controllerMessenger.subscribeOnceIf) - .mock.calls[0][1](txMeta as never); - - expect(result.current.transactionMetaIdsForListening).toEqual([]); - }); - }); }); diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx index 02b077daf47..2d25b183776 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.test.tsx @@ -14,13 +14,18 @@ jest.mock('./ResourceRing', () => ({ jest.mock('../../../../../locales/i18n', () => ({ strings: jest.fn().mockImplementation((key, vars) => { - if (key === 'asset_overview.tron.sufficient_to_cover_usdt') { - return `USDT ${vars?.amount}`; + switch (key) { + case 'asset_overview.tron.sufficient_to_cover_usdt_transfer': + return 'USDT 1'; + case 'asset_overview.tron.sufficient_to_cover_usdt_transfers': + return `USDT ${vars?.amount}`; + case 'asset_overview.tron.sufficient_to_cover_trx_transfer': + return 'TRX 1'; + case 'asset_overview.tron.sufficient_to_cover_trx_transfers': + return `TRX ${vars?.amount}`; + default: + return key; } - if (key === 'asset_overview.tron.sufficient_to_cover_trx') { - return `TRX ${vars?.amount}`; - } - return key; }), })); @@ -81,11 +86,13 @@ describe('TronEnergyBandwidthDetail', () => { const energyRingProps = ResourceRingMock.mock.calls[0][0]; expect(energyRingProps.icon).toBe(IconName.Flash); - expect(energyRingProps.progress).toBeCloseTo(130000 / (200000 + 70000), 5); + // 130000 current, 200000 max => 65% + expect(energyRingProps.progress).toBeCloseTo(0.65, 5); const bandwidthRingProps = ResourceRingMock.mock.calls[1][0]; expect(bandwidthRingProps.icon).toBe(IconName.Connect); - expect(bandwidthRingProps.progress).toBeCloseTo(560 / (1000 + 500), 5); + // 560 current, 1000 max => 56% + expect(bandwidthRingProps.progress).toBeCloseTo(0.56, 5); }); it('parses balances and caps progress', () => { diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.tsx b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.tsx index 85f5913c885..0992eee526e 100644 --- a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.tsx +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/TronEnergyBandwidthDetail.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSelector } from 'react-redux'; import { Box, Text, @@ -8,81 +7,24 @@ import { BoxAlignItems, BoxJustifyContent, IconName, + TextColor, } from '@metamask/design-system-react-native'; import { strings } from '../../../../../locales/i18n'; -import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; import ResourceRing from './ResourceRing'; -import { TRON_RESOURCE } from '../../../../core/Multichain/constants'; +import { useTronResources } from './useTronResources'; const TronEnergyBandwidthDetail = () => { - const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup); - - const { - energy, - bandwidth, - maxEnergy, - maxBandwidth, - strxEnergy, - strxBandwidth, - } = React.useMemo(() => { - let energy, bandwidth, maxEnergy, maxBandwidth, strxEnergy, strxBandwidth; - for (const asset of tronResources) { - switch (asset.symbol.toLowerCase()) { - case TRON_RESOURCE.ENERGY: - energy = asset; - break; - case TRON_RESOURCE.BANDWIDTH: - bandwidth = asset; - break; - case TRON_RESOURCE.MAX_ENERGY: - maxEnergy = asset; - break; - case TRON_RESOURCE.MAX_BANDWIDTH: - maxBandwidth = asset; - break; - case TRON_RESOURCE.STRX_ENERGY: - strxEnergy = asset; - break; - case TRON_RESOURCE.STRX_BANDWIDTH: - strxBandwidth = asset; - break; - } - } - return { - energy, - bandwidth, - maxEnergy, - maxBandwidth, - strxEnergy, - strxBandwidth, - }; - }, [tronResources]); - - const parseNum = (v?: string | number) => - typeof v === 'number' ? v : parseFloat(String(v ?? '0').replace(/,/g, '')); - - const energyValue = parseNum(energy?.balance); - const bandwidthValue = parseNum(bandwidth?.balance); - const maxBandwidthValue = parseNum(maxBandwidth?.balance); - const maxEnergyValue = parseNum(maxEnergy?.balance); - const strxEnergyValue = parseNum(strxEnergy?.balance); - const strxBandwidthValue = parseNum(strxBandwidth?.balance); - - const BANDWIDTH_MAX = Math.max(1, maxBandwidthValue + strxBandwidthValue); - const bandwidthProgress = Math.min(1, (bandwidthValue || 0) / BANDWIDTH_MAX); - - const ENERGY_MAX = Math.max(1, maxEnergyValue + strxEnergyValue); - const energyProgress = Math.min(1, (energyValue || 0) / ENERGY_MAX); + const { energy, bandwidth } = useTronResources(); // Info about how much energy and bandwidth is needed for a TRC20 transfer and a TRX transfer const ENERGY_PER_TRC20_TRANSFER_BASELINE = 65000; const BANDWIDTH_PER_TRX_TRANSFER_BASELINE = 280; const usdtTransfersCovered = Math.floor( - (energyValue || 0) / ENERGY_PER_TRC20_TRANSFER_BASELINE, + (energy.current || 0) / ENERGY_PER_TRC20_TRANSFER_BASELINE, ); const trxTxsCovered = Math.floor( - (bandwidthValue || 0) / BANDWIDTH_PER_TRX_TRANSFER_BASELINE, + (bandwidth.current || 0) / BANDWIDTH_PER_TRX_TRANSFER_BASELINE, ); return ( @@ -104,21 +46,40 @@ const TronEnergyBandwidthDetail = () => { alignItems={BoxAlignItems.Center} twClassName="gap-4" > - + {strings('asset_overview.tron.energy')} - - {strings('asset_overview.tron.sufficient_to_cover_usdt', { - amount: usdtTransfersCovered, - })} - + {usdtTransfersCovered === 1 ? ( + + {strings( + 'asset_overview.tron.sufficient_to_cover_usdt_transfer', + )} + + ) : ( + + {strings( + 'asset_overview.tron.sufficient_to_cover_usdt_transfers', + { + amount: usdtTransfersCovered, + }, + )} + + )} - - {energyValue ? energyValue.toLocaleString() : '0'} - + + + {energy.current ? energy.current.toLocaleString() : '0'} + + + /{energy.max.toLocaleString()} + + { alignItems={BoxAlignItems.Center} twClassName="gap-4" > - + {strings('asset_overview.tron.bandwidth')} - - {strings('asset_overview.tron.sufficient_to_cover_trx', { - amount: trxTxsCovered, - })} - + {trxTxsCovered === 1 ? ( + + {strings( + 'asset_overview.tron.sufficient_to_cover_trx_transfer', + )} + + ) : ( + + {strings( + 'asset_overview.tron.sufficient_to_cover_trx_transfers', + { amount: trxTxsCovered }, + )} + + )} - - {bandwidthValue ? bandwidthValue.toLocaleString() : '0'} - + + + {bandwidth.current ? bandwidth.current.toLocaleString() : '0'} + + + /{bandwidth.max.toLocaleString()} + + ); diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts new file mode 100644 index 00000000000..aa8cc90714a --- /dev/null +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.test.ts @@ -0,0 +1,140 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { renderHook } from '@testing-library/react-hooks'; +import { useSelector } from 'react-redux'; + +import { TRON_RESOURCE } from '../../../../core/Multichain/constants'; +import { useTronResources } from './useTronResources'; +import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; + +jest.mock('react-redux', () => ({ + useSelector: jest.fn(), +})); + +jest.mock('../../../../selectors/assets/assets-list', () => ({ + __esModule: true, + ...jest.requireActual('../../../../selectors/assets/assets-list'), + selectTronResourcesBySelectedAccountGroup: jest.fn(), +})); + +const mockUseSelector = useSelector as jest.MockedFunction; +const mockSelectTronResourcesBySelectedAccountGroup = + selectTronResourcesBySelectedAccountGroup as jest.MockedFunction< + typeof selectTronResourcesBySelectedAccountGroup + >; + +interface MockTronAsset { + symbol?: string; + balance?: string | number; +} + +describe('useTronResources', () => { + const createTronAsset = ( + symbol: string, + balance: string | number, + ): MockTronAsset => ({ + symbol, + balance, + }); + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseSelector.mockImplementation((selector: any) => selector()); + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([]); + }); + + it('builds energy and bandwidth resources from base max capacity', () => { + const tronResources: MockTronAsset[] = [ + createTronAsset(TRON_RESOURCE.ENERGY, '500'), + createTronAsset(TRON_RESOURCE.MAX_ENERGY, '1000'), + createTronAsset(TRON_RESOURCE.STRX_ENERGY, '500'), + createTronAsset(TRON_RESOURCE.BANDWIDTH, '300'), + createTronAsset(TRON_RESOURCE.MAX_BANDWIDTH, '600'), + createTronAsset(TRON_RESOURCE.STRX_BANDWIDTH, 0), + ]; + + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( + tronResources as any, + ); + + const { result } = renderHook(() => useTronResources()); + + expect(result.current.energy.current).toBe(500); + expect(result.current.energy.max).toBe(1000); + expect(result.current.energy.percentage).toBe(50); + + expect(result.current.bandwidth.current).toBe(300); + expect(result.current.bandwidth.max).toBe(600); + expect(result.current.bandwidth.percentage).toBe(50); + }); + + it('returns zeroed resources when no Tron resources exist', () => { + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue([] as any); + + const { result } = renderHook(() => useTronResources()); + + expect(result.current.energy).toEqual({ + type: 'energy', + current: 0, + max: 1, + percentage: 0, + }); + + expect(result.current.bandwidth).toEqual({ + type: 'bandwidth', + current: 0, + max: 1, + percentage: 0, + }); + }); + + it('parses balances with comma separators', () => { + const tronResources: MockTronAsset[] = [ + createTronAsset(TRON_RESOURCE.ENERGY, '1,000'), + createTronAsset(TRON_RESOURCE.MAX_ENERGY, '2,000'), + ]; + + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( + tronResources as any, + ); + + const { result } = renderHook(() => useTronResources()); + + expect(result.current.energy.current).toBe(1000); + expect(result.current.energy.max).toBe(2000); + expect(result.current.energy.percentage).toBe(50); + }); + + it('caps percentage at one hundred when current exceeds max', () => { + const tronResources: MockTronAsset[] = [ + createTronAsset(TRON_RESOURCE.ENERGY, 200), + createTronAsset(TRON_RESOURCE.MAX_ENERGY, 100), + ]; + + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( + tronResources as any, + ); + + const { result } = renderHook(() => useTronResources()); + + expect(result.current.energy.max).toBe(100); + expect(result.current.energy.percentage).toBe(100); + }); + + it('sets percentage to zero when balances cannot be parsed', () => { + const tronResources: MockTronAsset[] = [ + createTronAsset(TRON_RESOURCE.ENERGY, 'invalid'), + createTronAsset(TRON_RESOURCE.MAX_ENERGY, '1000'), + ]; + + mockSelectTronResourcesBySelectedAccountGroup.mockReturnValue( + tronResources as any, + ); + + const { result } = renderHook(() => useTronResources()); + + expect(result.current.energy.current).toBe(0); + expect(result.current.energy.max).toBe(1000); + expect(result.current.energy.percentage).toBe(0); + }); +}); diff --git a/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts new file mode 100644 index 00000000000..e8723a2d757 --- /dev/null +++ b/app/components/UI/AssetOverview/TronEnergyBandwidthDetail/useTronResources.ts @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import BigNumber from 'bignumber.js'; + +import { selectTronResourcesBySelectedAccountGroup } from '../../../../selectors/assets/assets-list'; +import { TRON_RESOURCE } from '../../../../core/Multichain/constants'; + +export interface TronResource { + type: 'energy' | 'bandwidth'; + current: number; + max: number; + /** + * Percentage of the resource that is currently available, in the range 0–100. + */ + percentage: number; +} + +function createResource( + type: TronResource['type'], + current: number, + max: number, +): TronResource { + const currentBN = new BigNumber(current); + const maxBN = new BigNumber(max); + const percentageBN = currentBN.dividedBy(maxBN).multipliedBy(100); + + const percentage = BigNumber.min( + 100, + BigNumber.max(0, percentageBN), + ).toNumber(); + + return { + type, + current, + max, + percentage, + }; +} + +/** + * Hook to build Tron daily resource data (energy and bandwidth) for the + * currently selected account group. + * + * It normalizes raw Tron resource assets into a simple model consumable by + * UI components. The max capacity is derived only from the base max values, + * matching the behavior used in the extension codebase. + */ +export const useTronResources = (): { + energy: TronResource; + bandwidth: TronResource; +} => { + const tronResources = useSelector(selectTronResourcesBySelectedAccountGroup); + + return useMemo(() => { + let energy; + let bandwidth; + let maxEnergy; + let maxBandwidth; + + // Extract the different Tron resource entries from the flat list. + for (const asset of tronResources) { + switch (asset.symbol?.toLowerCase()) { + case TRON_RESOURCE.ENERGY: + energy = asset; + break; + case TRON_RESOURCE.BANDWIDTH: + bandwidth = asset; + break; + case TRON_RESOURCE.MAX_ENERGY: + maxEnergy = asset; + break; + case TRON_RESOURCE.MAX_BANDWIDTH: + maxBandwidth = asset; + break; + default: + break; + } + } + + const parseValue = (value?: string | number): number => { + if (value === undefined || value === null) return 0; + // Remove commas from string values before parsing + const cleanValue = + typeof value === 'string' ? value.replace(/,/g, '') : value; + const num = Number(cleanValue); + return Number.isNaN(num) ? 0 : num; + }; + + const energyCurrent = parseValue(energy?.balance); + const bandwidthCurrent = parseValue(bandwidth?.balance); + const maxEnergyValue = parseValue(maxEnergy?.balance); + const maxBandwidthValue = parseValue(maxBandwidth?.balance); + + const energyMax = Math.max(1, maxEnergyValue); + const bandwidthMax = Math.max(1, maxBandwidthValue); + + return { + energy: createResource('energy', energyCurrent, energyMax), + bandwidth: createResource('bandwidth', bandwidthCurrent, bandwidthMax), + }; + }, [tronResources]); +}; diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts new file mode 100644 index 00000000000..60beddd5586 --- /dev/null +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/EarnMusdConversionEducationView.styles.ts @@ -0,0 +1,40 @@ +import { Platform, StyleSheet } from 'react-native'; +import type { Theme } from '../../../../../util/theme/models'; + +export const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.default, + gap: 24, + }, + backgroundImage: { + width: '100%', + height: 438, + resizeMode: 'cover', + }, + content: { + paddingHorizontal: 16, + justifyContent: 'center', + alignItems: 'center', + paddingBottom: 24, + }, + heading: { + marginBottom: 8, + fontFamily: + Platform.OS === 'android' ? 'MM Sans Regular' : 'MMSans-Regular', + }, + bodyText: { + marginBottom: 32, + textAlign: 'center', + }, + continueButton: { + alignSelf: 'stretch', + marginHorizontal: 32, + marginBottom: 24, + }, + }); +}; diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx new file mode 100644 index 00000000000..283d43368ae --- /dev/null +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.test.tsx @@ -0,0 +1,353 @@ +import React from 'react'; +import { fireEvent, waitFor, act } from '@testing-library/react-native'; +import { + useRoute, + useNavigation, + useFocusEffect, +} from '@react-navigation/native'; +import { useDispatch } from 'react-redux'; +import { Hex } from '@metamask/utils'; +import EarnMusdConversionEducationView from './index'; +import { + setMusdConversionEducationSeen, + UserActionType, +} from '../../../../../actions/user'; +import Logger from '../../../../../util/Logger'; +import renderWithProvider from '../../../../../util/test/renderWithProvider'; +import { strings } from '../../../../../../locales/i18n'; +import { useMusdConversion } from '../../hooks/useMusdConversion'; + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useRoute: jest.fn(), + useNavigation: jest.fn(), + useFocusEffect: jest.fn(), + }; +}); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +jest.mock('../../../../../actions/user', () => ({ + setMusdConversionEducationSeen: jest.fn(), +})); + +jest.mock('../../hooks/useMusdConversion', () => ({ + useMusdConversion: jest.fn(), +})); + +jest.mock('../../../../../util/Logger', () => ({ + error: jest.fn(), +})); + +jest.mock('../../../../../util/theme', () => ({ + useTheme: jest.fn().mockReturnValue({ + colors: { + background: { default: '#FFFFFF' }, + text: { default: '#000000' }, + }, + themeAppearance: 'light', + typography: {}, + shadows: {}, + brandColors: {}, + }), +})); + +const mockUseRoute = useRoute as jest.MockedFunction; +const mockUseNavigation = useNavigation as jest.MockedFunction< + typeof useNavigation +>; +const mockUseFocusEffect = useFocusEffect as jest.MockedFunction< + typeof useFocusEffect +>; +const mockUseDispatch = useDispatch as jest.MockedFunction; +const mockSetMusdConversionEducationSeen = + setMusdConversionEducationSeen as jest.MockedFunction< + typeof setMusdConversionEducationSeen + >; +const mockUseMusdConversion = useMusdConversion as jest.MockedFunction< + typeof useMusdConversion +>; +const mockLogger = Logger as jest.Mocked; + +describe('EarnMusdConversionEducationView', () => { + const mockDispatch = jest.fn(); + const mockInitiateConversion = jest.fn(); + const mockNavigation = { + setOptions: jest.fn(), + navigate: jest.fn(), + goBack: jest.fn(), + }; + + const mockRouteParams = { + preferredPaymentToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48' as Hex, + chainId: '0x1' as Hex, + }, + outputChainId: '0x1' as Hex, + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockUseDispatch.mockReturnValue(mockDispatch); + // @ts-expect-error - partial mock of navigation is sufficient for testing + mockUseNavigation.mockReturnValue(mockNavigation); + mockUseFocusEffect.mockImplementation((callback) => { + callback(); + }); + mockUseMusdConversion.mockReturnValue({ + initiateConversion: mockInitiateConversion, + error: null, + hasSeenMusdEducationScreen: false, + }); + mockSetMusdConversionEducationSeen.mockReturnValue({ + type: 'SET_MUSD_CONVERSION_EDUCATION_SEEN' as UserActionType.SET_MUSD_CONVERSION_EDUCATION_SEEN, + }); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('rendering', () => { + it('renders mUSD conversion education screen with all UI elements', () => { + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + expect( + getByText(strings('earn.musd_conversion.education.heading')), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.description')), + ).toBeOnTheScreen(); + expect( + getByText(strings('earn.musd_conversion.education.continue_button')), + ).toBeOnTheScreen(); + }); + }); + + describe('redux actions', () => { + it('dispatches setMusdConversionEducationSeen when continue button pressed', async () => { + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_MUSD_CONVERSION_EDUCATION_SEEN', + }); + }); + }); + + it('marks education as seen before initiating conversion', async () => { + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const callOrder: string[] = []; + + mockDispatch.mockImplementation(() => { + callOrder.push('dispatch'); + }); + mockInitiateConversion.mockImplementation(async () => { + callOrder.push('initiateConversion'); + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(callOrder).toEqual(['dispatch', 'initiateConversion']); + }); + }); + }); + + describe('conversion initiation', () => { + it('calls initiateConversion with correct params when outputChainId and preferredPaymentToken provided', async () => { + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockInitiateConversion).toHaveBeenCalledTimes(1); + expect(mockInitiateConversion).toHaveBeenCalledWith({ + outputChainId: mockRouteParams.outputChainId, + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + }); + }); + }); + + it('logs error when outputChainId missing but still marks education as seen', async () => { + const paramsWithoutOutputChainId = { + preferredPaymentToken: mockRouteParams.preferredPaymentToken, + }; + + mockUseRoute.mockReturnValue({ + params: paramsWithoutOutputChainId, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + new Error('Missing required parameters'), + '[mUSD Conversion Education] Cannot proceed without outputChainId and preferredPaymentToken', + ); + expect(mockInitiateConversion).not.toHaveBeenCalled(); + }); + }); + + it('logs error when preferredPaymentToken missing', async () => { + const paramsWithoutPaymentToken = { + outputChainId: mockRouteParams.outputChainId, + }; + + mockUseRoute.mockReturnValue({ + params: paramsWithoutPaymentToken, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockInitiateConversion).not.toHaveBeenCalled(); + }); + }); + }); + + describe('error handling', () => { + it('logs error when initiateConversion throws error', async () => { + const testError = new Error('Conversion failed'); + mockInitiateConversion.mockRejectedValue(testError); + + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockLogger.error).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith( + testError, + '[mUSD Conversion Education] Failed to initiate conversion', + ); + }); + }); + + it('still marks education as seen even if conversion fails', async () => { + const testError = new Error('Conversion failed'); + mockInitiateConversion.mockRejectedValue(testError); + + mockUseRoute.mockReturnValue({ + params: mockRouteParams, + key: 'test-key', + name: 'test-name', + }); + + const { getByText } = renderWithProvider( + , + { state: {} }, + ); + + await act(async () => { + fireEvent.press( + getByText(strings('earn.musd_conversion.education.continue_button')), + ); + }); + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_MUSD_CONVERSION_EDUCATION_SEEN', + }); + }); + }); + }); +}); diff --git a/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx new file mode 100644 index 00000000000..8c457e2e597 --- /dev/null +++ b/app/components/UI/Earn/Views/EarnMusdConversionEducationView/index.tsx @@ -0,0 +1,117 @@ +import React, { useCallback } from 'react'; +import { Hex } from '@metamask/utils'; +import { useDispatch } from 'react-redux'; +import { View, Image } from 'react-native'; +import { setMusdConversionEducationSeen } from '../../../../../actions/user'; +import Logger from '../../../../../util/Logger'; +import { strings } from '../../../../../../locales/i18n'; +import Text, { + TextColor, + TextVariant, +} from '../../../../../component-library/components/Texts/Text'; +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../../../component-library/components/Buttons/Button'; +import { useStyles } from '../../../../../component-library/hooks'; +import { styleSheet } from './EarnMusdConversionEducationView.styles'; +import musdEducationBackground from '../../../../../images/musd-education-screen-background-3x.png'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useMusdConversion } from '../../hooks/useMusdConversion'; +import { useParams } from '../../../../../util/navigation/navUtils'; +import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { getCloseOnlyNavbar } from '../../../Navbar'; +import { useTheme } from '../../../../../util/theme'; + +interface EarnMusdConversionEducationViewRouteParams { + /** + * The payment token to preselect in the confirmation screen + */ + preferredPaymentToken?: { + address: Hex; + chainId: Hex; + }; + /** + * The output token's chainId + */ + outputChainId: Hex; +} + +/** + * Displays educational content before user's first mUSD conversion. + * Once completed, marks the education as seen and proceeds to conversion flow. + */ +const EarnMusdConversionEducationView = () => { + const dispatch = useDispatch(); + const { initiateConversion } = useMusdConversion(); + const { preferredPaymentToken, outputChainId } = + useParams(); + const { styles } = useStyles(styleSheet, {}); + const navigation = useNavigation(); + const { colors } = useTheme(); + + useFocusEffect( + useCallback(() => { + navigation.setOptions(getCloseOnlyNavbar(navigation, colors)); + }, [navigation, colors]), + ); + + const handleContinue = useCallback(async () => { + try { + // Mark education as seen so it won't show again + dispatch(setMusdConversionEducationSeen()); + + // Proceed to conversion flow if we have the required params + if (outputChainId && preferredPaymentToken) { + await initiateConversion({ + outputChainId, + preferredPaymentToken, + }); + return; + } + + Logger.error( + new Error('Missing required parameters'), + '[mUSD Conversion Education] Cannot proceed without outputChainId and preferredPaymentToken', + ); + } catch (error) { + Logger.error( + error as Error, + '[mUSD Conversion Education] Failed to initiate conversion', + ); + } + }, [dispatch, initiateConversion, outputChainId, preferredPaymentToken]); + + return ( + + + + + + {strings('earn.musd_conversion.education.heading')} + + + + {strings('earn.musd_conversion.education.description')} + + +