Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions app/components/Nav/Main/MainNavigator.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,10 @@ import SendTransaction from '../../UI/Ramp/Aggregator/Views/SendTransaction';
import TabBar from '../../../component-library/components/Navigation/TabBar';
///: BEGIN:ONLY_INCLUDE_IF(snaps)
import { SnapsSettingsList } from '../../Views/Snaps/SnapsSettingsList';
import { SnapSettings } from '../../Views/Snaps/SnapSettings';
import {
SnapSettings,
ALLOWED_CAPABILITIES as SNAPS_SETTINGS_ROUTE_ALLOWED_CAPABILITIES,
} from '../../Views/Snaps/SnapSettings';
import { CAN_INSTALL_THIRD_PARTY_SNAPS } from '../../../constants/snaps';
///: END:ONLY_INCLUDE_IF
import Routes from '../../../constants/navigation/Routes';
Expand Down Expand Up @@ -168,6 +171,7 @@ import BenefitFullView from '../../UI/Rewards/Views/BenefitFullView';
import BenefitsFullView from '../../UI/Rewards/Views/BenefitsFullView';
import { getDeFiProtocolPositionDetailsNavbarOptions } from '../../UI/Navbar';
import MoneyTabPressTracker from '../../UI/Money/components/MoneyTabPressTracker';
import { withMessenger } from '../../../messengers/helpers/route-messenger-helpers';

const Stack = createStackNavigator();
const NativeStack = createNativeStackNavigator();
Expand Down Expand Up @@ -430,6 +434,10 @@ const ExploreHome = () => {
};

///: BEGIN:ONLY_INCLUDE_IF(snaps)
const SnapSettingsWithMessenger = withMessenger(SnapSettings, {
capabilities: SNAPS_SETTINGS_ROUTE_ALLOWED_CAPABILITIES,
});

const SnapsSettingsStack = () => {
const { colors } = useTheme();
return (
Expand All @@ -445,7 +453,7 @@ const SnapsSettingsStack = () => {
/>
<NativeStack.Screen
name={Routes.SNAPS.SNAP_SETTINGS}
component={SnapSettings}
component={SnapSettingsWithMessenger}
/>
</NativeStack.Navigator>
);
Expand Down
9 changes: 8 additions & 1 deletion app/components/UI/Bridge/hooks/useSubmitBatchSellTx/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
QuoteMetadata,
QuoteResponse,
} from '@metamask/bridge-controller';
import type { BridgeStatusController } from '@metamask/bridge-status-controller';

import Engine from '../../../../../core/Engine';
import { selectBatchSellDestToken } from '../../../../../core/redux/slices/bridge';
Expand Down Expand Up @@ -37,8 +38,14 @@ export function useSubmitBatchSellTx() {
: quoteResponse,
);

// Type assertion needed: QuoteResponse/QuoteMetadata are imported from
// @metamask/bridge-controller v74 but submitBatchSell expects types from
// the v75 copy nested in @metamask/bridge-status-controller (FeatureId
// enum is structurally identical but nominally incompatible).
return await Engine.context.BridgeStatusController.submitBatchSell({
quoteResponses: normalizedQuoteResponses,
quoteResponses: normalizedQuoteResponses as Parameters<
BridgeStatusController['submitBatchSell']
>[0]['quoteResponses'],
accountAddress: walletAddress,
location,
isStxEnabled: stxEnabled,
Expand Down
207 changes: 193 additions & 14 deletions app/components/UI/Money/Views/MoneyHomeView/MoneyHomeView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ jest.mock('../../../../../core/NavigationService', () => ({
}));

jest.mock('../../utils/moneyFormatFiat', () => ({
...jest.requireActual('../../utils/moneyFormatFiat'),
moneyFormatFiat: jest.fn(() => '$0.12'),
}));

Expand Down Expand Up @@ -584,7 +585,126 @@ describe('MoneyHomeView', () => {
});
});

it('MoneyHowItWorks stays mounted in noAccount state when balance is unfunded', () => {
describe('dust threshold gating', () => {
const dustMock = {
totalFiatFormatted: '$0.00',
musdFiatFormatted: '$0.00',
musdSHFvdFiatFormatted: '$0.00',
// 0.0001 < DUST_THRESHOLD (0.01) — displays as $0.00 but is above zero
totalFiatRaw: '0.0001',
withdrawableMusd: undefined,
isAggregatedBalanceLoading: false,
isBalanceFetchError: false,
isBalanceFetching: false,
refetchBalance: jest.fn(),
apyDecimal: 0.05,
apyPercent: 5,
apyPercentFormatted: '5%',
vaultApyQuery: { data: { apy: 0.05 }, isLoading: false },
musdBalanceQuery: { data: undefined, isLoading: false },
musdEquivalentBalanceQuery: { data: undefined, isLoading: false },
} as unknown as ReturnType<typeof useMoneyAccountBalance>;

beforeEach(() => {
mockUseMoneyAccountBalance.mockReturnValue(dustMock);
mockUseMoneyAccountTransactions.mockReturnValue({
allTransactions: [],
deposits: [],
transfers: [],
submittedTransactions: [],
moneyAddress: '0x0000000000000000000000000000000000000001',
mockDataEnabled: false,
});
});

it('disables Transfer button for sub-cent (dust) balance', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const transferButton = getByTestId(
MoneyActionButtonRowTestIds.TRANSFER_BUTTON,
);
expect(transferButton.props.accessibilityState?.disabled).toBe(true);
});

it('hides MoneyEarnings for dust balance', () => {
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);
expect(
queryByTestId(MoneyEarningsTestIds.CONTAINER),
).not.toBeOnTheScreen();
});

it('shows unfunded onboarding sections for dust balance', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
expect(getByTestId(MoneyHowItWorksTestIds.CONTAINER)).toBeOnTheScreen();
expect(getByTestId(MoneyWhatYouGetTestIds.CONTAINER)).toBeOnTheScreen();
});
});

describe('missing-rate (unavailable) state', () => {
beforeEach(() => {
mockUseMoneyAccountBalance.mockReturnValue({
totalFiatFormatted: undefined,
totalFiatRaw: undefined,
isAggregatedBalanceLoading: false,
isBalanceFetchError: false,
isBalanceFetching: false,
refetchBalance: jest.fn(),
apyPercent: 5,
vaultApyQuery: { data: { apy: 0.05 }, isLoading: false },
musdBalanceQuery: { data: undefined, isLoading: false },
musdEquivalentBalanceQuery: { data: undefined, isLoading: false },
} as unknown as ReturnType<typeof useMoneyAccountBalance>);
mockUseMoneyAccountTransactions.mockReturnValue({
allTransactions: [],
deposits: [],
transfers: [],
submittedTransactions: [],
moneyAddress: '0x0000000000000000000000000000000000000001',
mockDataEnabled: false,
});
});

it('disables Transfer button', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const transferButton = getByTestId(
MoneyActionButtonRowTestIds.TRANSFER_BUTTON,
);
expect(transferButton.props.accessibilityState?.disabled).toBe(true);
});

it('hides MoneyEarnings', () => {
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);
expect(
queryByTestId(MoneyEarningsTestIds.CONTAINER),
).not.toBeOnTheScreen();
});

it('hides unfunded onboarding sections — does not fake an empty account', () => {
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);
expect(
queryByTestId(MoneyHowItWorksTestIds.CONTAINER),
).not.toBeOnTheScreen();
expect(
queryByTestId(MoneyWhatYouGetTestIds.CONTAINER),
).not.toBeOnTheScreen();
});
});

describe('spendable balance regression guard', () => {
it('enables Transfer button for a balance above the dust threshold', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const transferButton = getByTestId(
MoneyActionButtonRowTestIds.TRANSFER_BUTTON,
);
expect(transferButton.props.accessibilityState?.disabled).toBeFalsy();
});

it('shows MoneyEarnings for a balance above the dust threshold', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);
expect(getByTestId(MoneyEarningsTestIds.CONTAINER)).toBeOnTheScreen();
});
});

it('hides MoneyHowItWorks in noAccount state (displayState is noAccount, not balance)', () => {
mockUseMoneyAccountInfo.mockReturnValue({
hasMoneyAccount: false,
primaryMoneyAccount: undefined,
Expand All @@ -593,7 +713,6 @@ describe('MoneyHomeView', () => {
mockUseMoneyAccountBalance.mockReturnValue({
totalFiatFormatted: '$0.00',
totalFiatRaw: '0',
tokenTotal: new BigNumber(0),
isAggregatedBalanceLoading: false,
isBalanceFetchError: false,
isBalanceFetching: false,
Expand All @@ -612,9 +731,12 @@ describe('MoneyHomeView', () => {
mockDataEnabled: false,
});

const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const { queryByTestId } = renderWithProvider(<MoneyHomeView />);

expect(getByTestId(MoneyHowItWorksTestIds.CONTAINER)).toBeOnTheScreen();
// hasBalanceValue is false in noAccount state, so unfunded sections are hidden.
expect(
queryByTestId(MoneyHowItWorksTestIds.CONTAINER),
).not.toBeOnTheScreen();
});
});

Expand Down Expand Up @@ -693,6 +815,62 @@ describe('MoneyHomeView', () => {
});
});

describe('transfer button disabled state', () => {
it('disables Transfer button when initial balance is loading', () => {
mockUseMoneyAccountBalance.mockReturnValue({
...mockUseMoneyAccountBalance.mock.results[0]?.value,
tokenTotal: undefined,
isAggregatedBalanceLoading: true,
isBalanceFetchError: false,
isBalanceFetching: true,
} as unknown as ReturnType<typeof useMoneyAccountBalance>);

const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const transferButton = getByTestId(
MoneyActionButtonRowTestIds.TRANSFER_BUTTON,
);

expect(transferButton.props.accessibilityState?.disabled).toBe(true);
});

it('disables Transfer button when account has zero balance', () => {
mockUseMoneyAccountBalance.mockReturnValue({
...mockUseMoneyAccountBalance.mock.results[0]?.value,
totalFiatFormatted: '$0.00',
totalFiatRaw: '0',
isAggregatedBalanceLoading: false,
isBalanceFetchError: false,
isBalanceFetching: false,
} as unknown as ReturnType<typeof useMoneyAccountBalance>);

const { getByTestId } = renderWithProvider(<MoneyHomeView />);
const transferButton = getByTestId(
MoneyActionButtonRowTestIds.TRANSFER_BUTTON,
);

expect(transferButton.props.accessibilityState?.disabled).toBe(true);
});

it('does not navigate to Transfer sheet when Transfer button is pressed while disabled', () => {
mockUseMoneyAccountBalance.mockReturnValue({
...mockUseMoneyAccountBalance.mock.results[0]?.value,
totalFiatFormatted: '$0.00',
totalFiatRaw: '0',
isAggregatedBalanceLoading: false,
isBalanceFetchError: false,
isBalanceFetching: false,
} as unknown as ReturnType<typeof useMoneyAccountBalance>);

const { getByTestId } = renderWithProvider(<MoneyHomeView />);

fireEvent.press(getByTestId(MoneyActionButtonRowTestIds.TRANSFER_BUTTON));

expect(mockNavigate).not.toHaveBeenCalledWith(Routes.MONEY.MODALS.ROOT, {
screen: Routes.MONEY.MODALS.TRANSFER_MONEY_SHEET,
});
});
});

it('navigates to Card root when Card button is pressed', () => {
const { getByTestId } = renderWithProvider(<MoneyHomeView />);

Expand Down Expand Up @@ -827,12 +1005,14 @@ describe('MoneyHomeView', () => {

it('drops the + prefix when projected earnings round to formatted zero', () => {
mockMoneyFormatFiat.mockReturnValue('$0.00');
// totalFiatRaw must be >= DUST_THRESHOLD so hasSpendableBalance is true
// and MoneyEarnings renders. moneyFormatFiat is mocked to return '$0.00'
// for all values, exercising the no-plus-prefix path.
mockUseMoneyAccountBalance.mockReturnValue({
totalFiatFormatted: '$0.00',
musdFiatFormatted: '$0.00',
musdSHFvdFiatFormatted: '$0.00',
totalFiatRaw: '0.001',
tokenTotal: undefined,
totalFiatFormatted: '$3.00',
musdFiatFormatted: '$1.00',
musdSHFvdFiatFormatted: '$2.00',
totalFiatRaw: '3',
isAggregatedBalanceLoading: false,
apyDecimal: 0.05,
apyPercent: 5,
Expand Down Expand Up @@ -1212,13 +1392,12 @@ describe('MoneyHomeView', () => {
});
});

describe('balance not ready (tokenTotal undefined)', () => {
describe('balance not ready (loading state)', () => {
beforeEach(() => {
mockUseMoneyAccountBalance.mockReturnValue({
totalFiatFormatted: '$3.00',
totalFiatRaw: '3',
tokenTotal: undefined,
isAggregatedBalanceLoading: false,
totalFiatFormatted: undefined,
totalFiatRaw: undefined,
isAggregatedBalanceLoading: true,
isBalanceFetchError: false,
isBalanceFetching: false,
refetchBalance: mockRefetchBalance,
Expand Down
Loading
Loading