Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
69 changes: 68 additions & 1 deletion app/components/UI/Earn/constants/musd.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { CHAIN_IDS } from '@metamask/transaction-controller';
import { isMusdToken, MUSD_TOKEN_ADDRESS_BY_CHAIN } from './musd';
import {
isMusdOnMoneyAccountChain,
isMusdToken,
isMusdTokenOnChain,
MUSD_TOKEN_ADDRESS_BY_CHAIN,
} from './musd';

describe('isMusdToken', () => {
const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET];
Expand Down Expand Up @@ -44,3 +49,65 @@ describe('isMusdToken', () => {
expect(result).toBe(false);
});
});

describe('isMusdTokenOnChain', () => {
const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET];

it('returns true for the mUSD address on a supported chain', () => {
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe(true);
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET)).toBe(
true,
);
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(true);
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true);
});

it('returns false for the mUSD address on an unsupported chain', () => {
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.POLYGON)).toBe(false);
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.ARBITRUM)).toBe(false);
expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.OPTIMISM)).toBe(false);
});

it('is case-insensitive', () => {
expect(
isMusdTokenOnChain(MUSD_ADDRESS.toUpperCase(), CHAIN_IDS.MAINNET),
).toBe(true);
});

it('returns false for missing address or chainId', () => {
expect(isMusdTokenOnChain(undefined, CHAIN_IDS.MAINNET)).toBe(false);
expect(isMusdTokenOnChain(MUSD_ADDRESS, undefined)).toBe(false);
});
});

describe('isMusdOnMoneyAccountChain', () => {
const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET];

it('returns true only for mUSD on Monad', () => {
expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true);
});

it('returns false for mUSD on chains where mUSD is deployed but the Money Account is not active', () => {
expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe(
false,
);
expect(
isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET),
).toBe(false);
expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(false);
});

it('returns false for missing arguments', () => {
expect(isMusdOnMoneyAccountChain(undefined, CHAIN_IDS.MONAD)).toBe(false);
expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, undefined)).toBe(false);
});

it('returns false for a non-mUSD address on Monad', () => {
expect(
isMusdOnMoneyAccountChain(
'0x1234567890123456789012345678901234567890',
CHAIN_IDS.MONAD,
),
).toBe(false);
});
});
35 changes: 35 additions & 0 deletions app/components/UI/Earn/constants/musd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ export const isMusdToken = (address?: string): boolean => {
return address.toLowerCase() === musdAddress.toLowerCase();
};

/**
* Like {@link isMusdToken} but also requires `chainId` to be a chain where
* mUSD is actually deployed. Prevents a same-address token on an unsupported
* chain from being misclassified as mUSD.
*/
export const isMusdTokenOnChain = (
address?: string,
chainId?: Hex,
): boolean => {
if (!address || !chainId) return false;
const expected = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId];
if (!expected) return false;
return address.toLowerCase() === expected.toLowerCase();
};

/**
* Chains where mUSD CTA should show (buy routes available).
* BSC is excluded as buy routes are not yet available.
Expand All @@ -60,6 +75,26 @@ export const MUSD_BUYABLE_CHAIN_IDS: Hex[] = [
// CHAIN_IDS.BSC, // TODO: Uncomment once buy routes are available
];

/**
* Chains where the Money Account surfaces mUSD activity. mUSD exists on
* several chains for buy/convert flows, but Money Account currently only
* tracks Monad — inbound mUSD on Mainnet/Linea/BSC is unrelated to it and
* must not appear in Money activity.
*/
export const MUSD_MONEY_ACCOUNT_CHAIN_IDS: Hex[] = [CHAIN_IDS.MONAD];

/**
* Like {@link isMusdTokenOnChain} but restricted to chains where the Money
* Account is active (currently Monad only).
*/
export const isMusdOnMoneyAccountChain = (
address?: string,
chainId?: Hex,
): boolean => {
if (!chainId || !MUSD_MONEY_ACCOUNT_CHAIN_IDS.includes(chainId)) return false;
return isMusdTokenOnChain(address, chainId);
};

export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record<Hex, string> = {
[CHAIN_IDS.MAINNET]:
'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { StyleSheet } from 'react-native';
import { Theme } from '../../../../../util/theme/models';

const styleSheet = (_params: { theme: Theme }) =>
StyleSheet.create({
container: {
paddingInline: 16,
},
hero: {
marginVertical: 12,
},
});

export default styleSheet;
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import React from 'react';
import {
type TransactionMeta,
TransactionStatus,
TransactionType,
CHAIN_IDS,
} from '@metamask/transaction-controller';
import { merge } from 'lodash';
import renderWithProvider from '../../../../../util/test/renderWithProvider';
import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails';
import { otherControllersMock } from '../../../../Views/confirmations/__mocks__/controllers/other-controllers-mock';
import { MUSD_TOKEN_ADDRESS } from '../../../Earn/constants/musd';
import { MoneyReceivedDetails } from './MoneyReceivedDetails';

jest.mock(
'../../../../Views/confirmations/hooks/activity/useTransactionDetails',
);
jest.mock(
'../../../../Views/confirmations/components/activity/transaction-details-status-row',
() => ({
TransactionDetailsStatusRow: jest.fn(() => null),
}),
);
jest.mock(
'../../../../Views/confirmations/components/activity/transaction-details-date-row',
() => ({
TransactionDetailsDateRow: jest.fn(() => null),
}),
);
jest.mock('../../../Name/Name', () => {
const { Text } = jest.requireActual('react-native');
// eslint-disable-next-line react/display-name
return ({ value }: { value: string }) => (
<Text testID="name-mock">{value}</Text>
);
});
jest.mock('../../../../Views/confirmations/components/token-icon', () => ({
TokenIcon: () => null,
TokenIconVariant: { Row: 'row' },
}));

const FROM_MOCK = '0x1111111111111111111111111111111111111111';
const RECIPIENT_MOCK = '0x2222222222222222222222222222222222222222';

const baseTransactionMeta = {
id: 'tx-1',
chainId: CHAIN_IDS.MONAD,
status: TransactionStatus.confirmed,
time: Date.UTC(2026, 4, 21, 14, 16),
type: TransactionType.incoming,
txParams: {
from: FROM_MOCK,
to: RECIPIENT_MOCK,
},
transferInformation: {
contractAddress: MUSD_TOKEN_ADDRESS,
decimals: 6,
symbol: 'mUSD',
amount: '500000',
},
} as unknown as TransactionMeta;

function render(overrides: Partial<TransactionMeta> = {}) {
const useTransactionDetailsMock = jest.mocked(useTransactionDetails);
useTransactionDetailsMock.mockReturnValue({
transactionMeta: {
...baseTransactionMeta,
...overrides,
} as TransactionMeta,
});
return renderWithProvider(<MoneyReceivedDetails />, {
state: merge({}, otherControllersMock),
});
}

describe('MoneyReceivedDetails', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('renders the From row with the sender address for an incoming transfer', () => {
const { getByTestId } = render();
expect(getByTestId('name-mock').props.children).toBe(FROM_MOCK);
});

it('renders the token received label with the mUSD symbol', () => {
const { getByText } = render();
expect(getByText('Token received')).toBeTruthy();
expect(getByText('mUSD')).toBeTruthy();
});

it('renders the fiat hero when buildMoneyActivityFiatLine returns a value', () => {
const { queryByTestId } = render();
expect(queryByTestId('money-received-hero')).toBeTruthy();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests use toBeTruthy instead of toBeOnTheScreen

Low Severity

Several new test assertions use toBeTruthy() to verify element presence (e.g., expect(getByText('Token received')).toBeTruthy() and expect(getByTestId('money-received-details')).toBeTruthy()). The unit testing guidelines mandate toBeOnTheScreen() for element presence checks — toBeTruthy() is listed as a banned weak matcher for this purpose.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by project rule: Unit Testing Guidelines

Reviewed by Cursor Bugbot for commit 7e966e7. Configure here.

});

it('omits the From row when txParams.from is missing', () => {
const { queryByTestId, queryByText } = render({
txParams: { to: RECIPIENT_MOCK } as TransactionMeta['txParams'],
});
expect(queryByText('From')).toBeNull();
expect(queryByTestId('name-mock')).toBeNull();
});

it('omits the token received row when no mUSD transfer meta can be resolved', () => {
const { queryByText } = render({
transferInformation: undefined,
txParams: {
from: FROM_MOCK,
to: RECIPIENT_MOCK,
data: '0x',
} as TransactionMeta['txParams'],
});
expect(queryByText('Token received')).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import React from 'react';
import { ScrollView } from 'react-native';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScrollView from react-native inside BottomSheet breaks scrolling

Medium Severity

ScrollView is imported from react-native in MoneyReceivedDetails, but this component is rendered inside a BottomSheet (in MoneyTransactionDetailsSheet). The gesture-handler version is required for scroll/gesture coordination inside bottom sheets — without it, scroll gestures conflict with the sheet's pan gesture, causing broken scrolling or accidental sheet dismissal. The import needs to come from react-native-gesture-handler instead.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: ScrollView inside BottomSheet must be imported from react-native-gesture-handler

Reviewed by Cursor Bugbot for commit 7e966e7. Configure here.

import { useSelector } from 'react-redux';
import { type Hex } from '@metamask/utils';
import { Text, TextVariant } from '@metamask/design-system-react-native';
import { strings } from '../../../../../../locales/i18n';
import { useStyles } from '../../../../../component-library/hooks';
import {
selectCurrencyRates,
selectCurrentCurrency,
} from '../../../../../selectors/currencyRateController';
import { selectTokenMarketData } from '../../../../../selectors/tokenRatesController';
import { Box } from '../../../Box/Box';
import { AlignItems, FlexDirection } from '../../../Box/box.types';
import Name from '../../../Name/Name';
import { NameType } from '../../../Name/Name.types';
import { TransactionDetailDivider } from '../../../../Views/confirmations/components/activity/transaction-detail-divider/transaction-detail-divider';
import { TransactionDetailsDateRow } from '../../../../Views/confirmations/components/activity/transaction-details-date-row';
import { TransactionDetailsRow } from '../../../../Views/confirmations/components/activity/transaction-details-row/transaction-details-row';
import { TransactionDetailsStatusRow } from '../../../../Views/confirmations/components/activity/transaction-details-status-row';
import {
TokenIcon,
TokenIconVariant,
} from '../../../../Views/confirmations/components/token-icon';
import { useTransactionDetails } from '../../../../Views/confirmations/hooks/activity/useTransactionDetails';
import { resolveMusdTransferMeta } from '../../constants/activityStyles';
import { buildMoneyActivityFiatLine } from '../../utils/moneyActivityFiat';
import styleSheet from './MoneyReceivedDetails.styles';

export function MoneyReceivedDetails() {
const { styles } = useStyles(styleSheet, {});
const { transactionMeta } = useTransactionDetails();
const currentCurrency = useSelector(selectCurrentCurrency);
const currencyRates = useSelector(selectCurrencyRates);
const tokenMarketData = useSelector(selectTokenMarketData);

const fiatAmount = buildMoneyActivityFiatLine(
transactionMeta,
currencyRates,
currentCurrency,
tokenMarketData,
);
const tokenMeta = resolveMusdTransferMeta(transactionMeta);
const from = transactionMeta.txParams?.from;
const chainId = transactionMeta.chainId as Hex;

return (
<ScrollView>
<Box style={styles.container} gap={12}>
{fiatAmount ? (
<Box
testID="money-received-hero"
alignItems={AlignItems.center}
gap={12}
style={styles.hero}
>
<Text variant={TextVariant.DisplayLg}>{fiatAmount}</Text>
</Box>
) : null}
<TransactionDetailsStatusRow />
<TransactionDetailsDateRow />
<TransactionDetailDivider />
{from ? (
<TransactionDetailsRow
label={strings('transaction_details.label.from')}
>
<Name
type={NameType.EthereumAddress}
value={from}
variation={chainId}
/>
</TransactionDetailsRow>
) : null}
{tokenMeta ? (
<TransactionDetailsRow
label={strings('transaction_details.label.token_received')}
>
<Box
flexDirection={FlexDirection.Row}
gap={6}
alignItems={AlignItems.center}
>
<TokenIcon
chainId={chainId}
address={tokenMeta.contractAddress as Hex}
symbol={tokenMeta.symbol}
variant={TokenIconVariant.Row}
/>
<Text>{tokenMeta.symbol}</Text>
</Box>
</TransactionDetailsRow>
) : null}
</Box>
</ScrollView>
);
}
Loading
Loading