diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 7e2004fa4c6..15be8d3d86d 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/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml b/.github/workflows/rerun-ci-on-skipped-e2e-labels.yml index 1ada12ea5f6..6793ca19fb0 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" diff --git a/android/app/build.gradle b/android/app/build.gradle index 1f808b24750..4647d5eeced 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 635d2e01646..c83796ffed7 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 diff --git a/app/components/Nav/Main/index.js b/app/components/Nav/Main/index.js index e75f841f078..406b078070c 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 642344d59be..9b8d208b42f 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 00000000000..cfad4916ba2 --- /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 00000000000..3602db96837 --- /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 00000000000..5aa573cdaa9 --- /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 00000000000..07a6fd82e64 --- /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 00000000000..b3a08e74caa --- /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 00000000000..bc046ff40aa --- /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 0e2c5549095..c8f4bcb2a57 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/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx b/app/components/UI/Perps/components/PerpsOrderTypeBottomSheet/PerpsOrderTypeBottomSheet.tsx index 1813e4de2ee..a4f39182ff3 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')} diff --git a/app/core/NotificationManager.js b/app/core/NotificationManager.js index 9e28e4713d8..2a45aaa4015 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/docs/readme/agent-device.md b/docs/readme/agent-device.md new file mode 100644 index 00000000000..a83c29b1294 --- /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/locales/languages/en.json b/locales/languages/en.json index f7b22a73522..4dbaa0ab7ba 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.", diff --git a/package.json b/package.json index 7414345e2f3..d2cc9fc5559 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", @@ -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 65d73799912..4cd553c1bc7 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" @@ -35547,7 +35569,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" @@ -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"