Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,10 @@ import {
selectCurrentCurrency,
} from '../../../../../selectors/currencyRateController';
import { selectNetworkConfigurations } from '../../../../../selectors/networkController';
import { selectStablecoins } from '../../../../../selectors/featureFlagController/stableTokens';
import { useMemo } from 'react';
import { useDeepMemo } from '../useDeepMemo';
import { toChecksumAddress } from '../../../../../util/address';
import { CHAIN_IDS } from '@metamask/transaction-controller';

// Pending conversion to a remote feature flag
const STABLECOINS: Record<Hex, Hex[]> = {
[CHAIN_IDS.MAINNET]: [
'0xaca92e438df0b2401ff60da7e4337b687a2435da', // MUSD
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', // USDC
'0xdac17f958d2ee523a2206206994597c13d831ec7', // USDT
],
[CHAIN_IDS.ARBITRUM]: [
'0xaf88d065e77c8cc2239327c5edb3a432268e5831', // USDC
],
[CHAIN_IDS.LINEA_MAINNET]: [
'0xaca92e438df0b2401ff60da7e4337b687a2435da', // MUSD
'0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC
'0xa219439258ca9da29e9cc4ce5596924745e12b93', // USDT
],
[CHAIN_IDS.POLYGON]: [
'0x2791bca1f2de4661ed88a30c99a7a9449aa84174', // USDC.e
'0xc011a7e12a19f7b1f670d46f03b03f3342e82dfb', // pUSD
],
};

export interface TokenFiatRateRequest {
address: Hex;
Expand All @@ -43,6 +22,7 @@ export function useTokenFiatRates(requests: TokenFiatRateRequest[]) {
const tokenMarketDataByAddressByChainId = useSelector(selectTokenMarketData);
const currencyRates = useSelector(selectCurrencyRates);
const networkConfigurations = useSelector(selectNetworkConfigurations);
const stablecoins = useSelector(selectStablecoins);
const safeRequests = useDeepMemo(() => requests, [requests]);

const result = useMemo(
Expand All @@ -51,7 +31,7 @@ export function useTokenFiatRates(requests: TokenFiatRateRequest[]) {
const currency = currencyOverride ?? selectedCurrency;
const isUsd = currency.toLowerCase() === 'usd';

const isStablecoin = STABLECOINS[chainId]?.includes(
const isStablecoin = stablecoins[chainId]?.includes(
Comment thread
jpuri marked this conversation as resolved.
address.toLowerCase() as Hex,
);

Expand Down Expand Up @@ -82,6 +62,7 @@ export function useTokenFiatRates(requests: TokenFiatRateRequest[]) {
networkConfigurations,
safeRequests,
selectedCurrency,
stablecoins,
tokenMarketDataByAddressByChainId,
],
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
selectDepositLimits,
} from '../../../../../selectors/featureFlagController/confirmations';
import { selectAccountOverrideByTransactionId } from '../../../../../selectors/transactionPayController';
import { isStablecoin } from '../../utils/token';
import { isRouteToken } from '../../utils/relayFixedSpread';
import { useTransactionPayToken } from '../pay/useTransactionPayToken';
import { useTransactionMetadataRequest } from './useTransactionMetadataRequest';
import { getMoneyAccountDepositIntent } from '../../../../UI/Money/hooks/useMoneyAccount';
Expand All @@ -33,9 +33,9 @@ jest.mock(
}),
);

jest.mock('../../utils/token', () => ({
...jest.requireActual('../../utils/token'),
isStablecoin: jest.fn(),
jest.mock('../../utils/relayFixedSpread', () => ({
...jest.requireActual('../../utils/relayFixedSpread'),
isRouteToken: jest.fn(),
}));

jest.mock('../pay/useTransactionPayToken');
Expand All @@ -60,7 +60,7 @@ const useTransactionPayTokenMock = jest.mocked(useTransactionPayToken);
const selectMetaMaskPayFlagsMock =
selectMetaMaskPayFlags as unknown as jest.Mock;
const selectDepositLimitsMock = selectDepositLimits as unknown as jest.Mock;
const isStablecoinMock = isStablecoin as unknown as jest.Mock;
const isRouteTokenMock = isRouteToken as unknown as jest.Mock;
const selectAccountOverrideMock =
selectAccountOverrideByTransactionId as unknown as jest.Mock;
const getMoneyAccountDepositIntentMock = jest.mocked(
Expand Down Expand Up @@ -129,7 +129,7 @@ function setupMocks(
},
});
selectDepositLimitsMock.mockReturnValue(depositLimits);
isStablecoinMock.mockReturnValue(stablecoin);
isRouteTokenMock.mockReturnValue(stablecoin);
selectAccountOverrideMock.mockReturnValue(overrides.accountOverride);
getMoneyAccountDepositIntentMock.mockReturnValue(
depositIntent as ReturnType<typeof getMoneyAccountDepositIntent>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
TransactionMeta,
TransactionType,
} from '@metamask/transaction-controller';
import { Hex } from '@metamask/utils';
import { useSelector } from 'react-redux';
import {
selectDepositLimits,
Expand All @@ -15,7 +14,7 @@ import {
import { selectAccountOverrideByTransactionId } from '../../../../../selectors/transactionPayController';
import { RootState } from '../../../../../reducers';
import { hasTransactionType } from '../../utils/transaction';
import { isStablecoin } from '../../utils/token';
import { isRouteToken } from '../../utils/relayFixedSpread';
import { useTransactionMetadataRequest } from './useTransactionMetadataRequest';
import { useTransactionPayToken } from '../pay/useTransactionPayToken';

Expand Down Expand Up @@ -73,11 +72,10 @@ export function useDepositPrefillAmount(): DepositPrefillResult {
return undefined;
}

const stable = isStablecoin(
payToken.address as Hex,
payToken.chainId as Hex,
relayFixedSpread,
);
const stable = isRouteToken(relayFixedSpread, {
chainId: payToken.chainId,
address: payToken.address,
});
const percentage = stable ? 100 : 50;

const raw = new BigNumber(percentage)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
selectMetaMaskPayFlags,
selectDepositLimits,
} from '../../../../../selectors/featureFlagController/confirmations';
import { isStablecoin } from '../../utils/token';
import { isRouteToken } from '../../utils/relayFixedSpread';
import { getMoneyAccountDepositIntent } from '../../../../UI/Money/hooks/useMoneyAccount';

jest.mock(
Expand All @@ -58,9 +58,9 @@ jest.mock(
selectDepositLimits: jest.fn(),
}),
);
jest.mock('../../utils/token', () => ({
...jest.requireActual('../../utils/token'),
isStablecoin: jest.fn(),
jest.mock('../../utils/relayFixedSpread', () => ({
...jest.requireActual('../../utils/relayFixedSpread'),
isRouteToken: jest.fn(),
}));
jest.mock('../../../../UI/Money/hooks/useMoneyAccount', () => ({
...jest.requireActual('../../../../UI/Money/hooks/useMoneyAccount'),
Expand Down Expand Up @@ -221,7 +221,7 @@ describe('useTransactionCustomAmount', () => {
},
});
(selectDepositLimits as unknown as jest.Mock).mockReturnValue({});
(isStablecoin as unknown as jest.Mock).mockReturnValue(false);
(isRouteToken as unknown as jest.Mock).mockReturnValue(false);
});

it('returns pending amount provided by updatePendingAmount', async () => {
Expand Down Expand Up @@ -1451,7 +1451,7 @@ describe('useTransactionCustomAmount', () => {
});

it('prefills stablecoin with 100% of balance', async () => {
(isStablecoin as unknown as jest.Mock).mockReturnValue(true);
(isRouteToken as unknown as jest.Mock).mockReturnValue(true);
useTransactionPayTokenMock.mockReturnValue({
payToken: {
address: TOKEN_ADDRESS_MOCK,
Expand All @@ -1472,7 +1472,7 @@ describe('useTransactionCustomAmount', () => {
});

it('prefills non-stablecoin with 50% of balance', async () => {
(isStablecoin as unknown as jest.Mock).mockReturnValue(false);
(isRouteToken as unknown as jest.Mock).mockReturnValue(false);
useTransactionPayTokenMock.mockReturnValue({
payToken: {
address: TOKEN_ADDRESS_MOCK,
Expand All @@ -1493,7 +1493,7 @@ describe('useTransactionCustomAmount', () => {
});

it('caps at deposit limit when balance exceeds it', async () => {
(isStablecoin as unknown as jest.Mock).mockReturnValue(true);
(isRouteToken as unknown as jest.Mock).mockReturnValue(true);
useTransactionPayTokenMock.mockReturnValue({
payToken: {
address: TOKEN_ADDRESS_MOCK,
Expand Down Expand Up @@ -1533,7 +1533,7 @@ describe('useTransactionCustomAmount', () => {
});

it('resets amountFiat to zero when switching to zero-balance token', async () => {
(isStablecoin as unknown as jest.Mock).mockReturnValue(true);
(isRouteToken as unknown as jest.Mock).mockReturnValue(true);
useTransactionPayTokenMock.mockReturnValue({
payToken: {
address: TOKEN_ADDRESS_MOCK,
Expand Down Expand Up @@ -1569,7 +1569,7 @@ describe('useTransactionCustomAmount', () => {
});

it('only prefills once even if balance changes', async () => {
(isStablecoin as unknown as jest.Mock).mockReturnValue(true);
(isRouteToken as unknown as jest.Mock).mockReturnValue(true);
useTransactionPayTokenMock.mockReturnValue({
payToken: {
address: TOKEN_ADDRESS_MOCK,
Expand Down
17 changes: 17 additions & 0 deletions app/components/Views/confirmations/utils/relayFixedSpread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,23 @@ export const isSubsidizedSource = (
* Reserved for callsites that know the destination of the transaction being
* built; not yet wired in to the MM Pay picker.
*/
/**
* Returns true when the given token appears in any route as either source or
* target. Used to decide whether a pay token is a stablecoin for prefill
* percentage logic.
*/
export const isRouteToken = (
config: RelayFixedSpreadConfig,
endpoint: RouteEndpoint,
): boolean =>
config.routes.some(
(route) =>
(addressesEqual(route.sourceChain, endpoint.chainId) &&
addressesEqual(route.sourceToken, endpoint.address)) ||
(addressesEqual(route.targetChain, endpoint.chainId) &&
addressesEqual(route.targetToken, endpoint.address)),
);

export const isSubsidizedRoute = (
config: RelayFixedSpreadConfig,
source: RouteEndpoint,
Expand Down
99 changes: 36 additions & 63 deletions app/components/Views/confirmations/utils/token.test.ts
Original file line number Diff line number Diff line change
@@ -1,111 +1,84 @@
import { CHAIN_IDS } from '@metamask/transaction-controller';
import { Hex } from '@metamask/utils';
import { isStablecoin } from './token';
import { RelayFixedSpreadConfig } from './relayFixedSpread';
import { isRouteToken, RelayFixedSpreadConfig } from './relayFixedSpread';

const MOCK_CONFIG: RelayFixedSpreadConfig = {
routes: [
{
sourceChain: CHAIN_IDS.MAINNET as Hex,
sourceToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex, // USDC
sourceToken: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48' as Hex,
targetChain: CHAIN_IDS.MAINNET as Hex,
targetToken: '0xdac17f958d2ee523a2206206994597c13d831ec7' as Hex, // USDT
targetToken: '0xdac17f958d2ee523a2206206994597c13d831ec7' as Hex,
},
{
sourceChain: CHAIN_IDS.BSC as Hex,
sourceToken: '0x55d398326f99059ff775485246999027b3197955' as Hex, // USDT
sourceToken: '0x55d398326f99059ff775485246999027b3197955' as Hex,
targetChain: CHAIN_IDS.BSC as Hex,
targetToken: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d' as Hex, // USDC
targetToken: '0x8ac76a51cc950d9822d68b83fe1ad97b32cd580d' as Hex,
},
{
sourceChain: CHAIN_IDS.LINEA_MAINNET as Hex,
sourceToken: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff' as Hex, // USDC
sourceToken: '0x176211869ca2b568f2a7d4ee941e073a821ee1ff' as Hex,
targetChain: CHAIN_IDS.LINEA_MAINNET as Hex,
targetToken: '0xa219439258ca9da29e9cc4ce5596924745e12b93' as Hex, // USDT
targetToken: '0xa219439258ca9da29e9cc4ce5596924745e12b93' as Hex,
},
],
};

const EMPTY_CONFIG: RelayFixedSpreadConfig = { routes: [] };

describe('isStablecoin', () => {
it('returns true for USDC on Ethereum (0x1)', () => {
describe('isRouteToken', () => {
it('returns true for a source token', () => {
expect(
isStablecoin(
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
CHAIN_IDS.MAINNET as Hex,
MOCK_CONFIG,
),
isRouteToken(MOCK_CONFIG, {
chainId: CHAIN_IDS.MAINNET,
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
}),
).toBe(true);
});

it('returns true for USDT on BSC (0x38)', () => {
it('returns true for a target token', () => {
expect(
isStablecoin(
'0x55d398326f99059ff775485246999027b3197955',
CHAIN_IDS.BSC as Hex,
MOCK_CONFIG,
),
isRouteToken(MOCK_CONFIG, {
chainId: CHAIN_IDS.MAINNET,
address: '0xdac17f958d2ee523a2206206994597c13d831ec7',
}),
).toBe(true);
});

it('returns true for a target token in a route', () => {
it('returns false for unknown address', () => {
expect(
isStablecoin(
'0xdac17f958d2ee523a2206206994597c13d831ec7',
CHAIN_IDS.MAINNET as Hex,
MOCK_CONFIG,
),
).toBe(true);
});

it('returns false for non-stablecoin address', () => {
expect(
isStablecoin(
'0x0000000000000000000000000000000000000001',
CHAIN_IDS.MAINNET as Hex,
MOCK_CONFIG,
),
isRouteToken(MOCK_CONFIG, {
chainId: CHAIN_IDS.MAINNET,
address: '0x0000000000000000000000000000000000000001',
}),
).toBe(false);
});

it('returns false for unknown chainId', () => {
expect(
isStablecoin(
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
'0xfffff' as Hex,
MOCK_CONFIG,
),
isRouteToken(MOCK_CONFIG, {
chainId: '0xfffff',
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
}),
).toBe(false);
});

it('returns false with empty config', () => {
expect(
isStablecoin(
'0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
CHAIN_IDS.MAINNET as Hex,
EMPTY_CONFIG,
),
isRouteToken(EMPTY_CONFIG, {
chainId: CHAIN_IDS.MAINNET,
address: '0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48',
}),
).toBe(false);
});

it('handles case-insensitive addresses (uppercase input)', () => {
expect(
isStablecoin(
'0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48',
CHAIN_IDS.MAINNET as Hex,
MOCK_CONFIG,
),
).toBe(true);
});

it('matches lowercase route tokens against lowercase input', () => {
it('handles case-insensitive addresses', () => {
expect(
isStablecoin(
'0x176211869ca2b568f2a7d4ee941e073a821ee1ff',
CHAIN_IDS.LINEA_MAINNET as Hex,
MOCK_CONFIG,
),
isRouteToken(MOCK_CONFIG, {
chainId: CHAIN_IDS.MAINNET,
address: '0xA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48',
}),
).toBe(true);
});
});
Loading
Loading