Skip to content
Draft
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
5 changes: 4 additions & 1 deletion app/components/UI/Bridge/hooks/useAssetMetadata/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useAsyncResult } from '../../../../hooks/useAsyncResult';
import { selectBasicFunctionalityEnabled } from '../../../../../selectors/settings';
import { isAddress as isSolanaAddress } from '@solana/addresses';
import { isAddress as isEvmAddress } from 'ethers/lib/utils';
import { isTronAddress } from '../../../../../core/Multichain/utils';

export enum AssetType {
/** The native asset for the current network, such as ETH */
Expand Down Expand Up @@ -58,7 +59,9 @@ export const useAssetMetadata = (

const trimmedSearchQuery = searchQuery.trim();
const isAddress =
isSolanaAddress(trimmedSearchQuery) || isEvmAddress(trimmedSearchQuery);
isSolanaAddress(trimmedSearchQuery) ||
isEvmAddress(trimmedSearchQuery) ||
isTronAddress(trimmedSearchQuery);

if (isBasicFunctionalityEnabled && shouldFetchMetadata && isAddress) {
const metadata = await fetchAssetMetadata(trimmedSearchQuery, chainId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,43 @@ describe('useAssetMetadata', () => {
});
});

describe('Tron', () => {
it('should fetch and return asset metadata for a base58 Tron address', async () => {
const mockChainIdTrx = 'tron:728126428';
const mockSearchQueryTrx = 'TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6';
const mockMetadata = {
address: mockSearchQueryTrx,
symbol: 'HTX',
decimals: 18,
assetId: `${mockChainIdTrx}/trc20:${mockSearchQueryTrx}`,
chainId: mockChainIdTrx,
};

mockFetchAssetMetadata.mockResolvedValueOnce(mockMetadata);

const { result } = renderHook(() =>
useAssetMetadata(mockSearchQueryTrx, true, mockChainIdTrx),
);

await waitFor(() => {
expect(result.current.assetMetadata).toEqual({
...mockMetadata,
chainId: mockChainIdTrx,
isNative: false,
type: AssetType.token,
image: 'mock-image-url',
balance: '',
string: '',
});
});

expect(mockFetchAssetMetadata).toHaveBeenCalledWith(
mockSearchQueryTrx,
mockChainIdTrx,
);
});
});

it('should return undefined when fetchAssetMetadata returns undefined', async () => {
mockFetchAssetMetadata.mockResolvedValueOnce(undefined);

Expand Down
41 changes: 41 additions & 0 deletions app/components/UI/Bridge/hooks/useAssetMetadata/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ describe('asset-utils', () => {
expect(result).toBe(`${MultichainNetwork.Solana}/token:${address}`);
});

it('should create Tron trc20 asset ID correctly', () => {
const address = 'TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6';
const chainId = 'tron:728126428' as CaipChainId;

const result = toAssetId(address, chainId);
expect(result).toBe(`tron:728126428/trc20:${address}`);
});

it('should create EVM token asset ID correctly', () => {
const address = '0x1f9840a85d5af5bf1d1762f925bdaddc4201f984';
const chainId = 'eip155:1' as CaipChainId;
Expand Down Expand Up @@ -169,6 +177,39 @@ describe('asset-utils', () => {
});
});

it('should fetch Tron token metadata successfully', async () => {
const tronChainId = 'tron:728126428' as CaipChainId;
const tronAddress = 'TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6';
const tronAssetId = `${tronChainId}/trc20:${tronAddress}`;

const mockMetadata = {
assetId: tronAssetId,
symbol: 'HTX',
name: 'HTX DAO',
decimals: 18,
};

(handleFetch as jest.Mock).mockResolvedValueOnce([mockMetadata]);

const result = await fetchAssetMetadata(tronAddress, tronChainId);

expect(handleFetch).toHaveBeenCalledWith(
`${TOKEN_API_V3_BASE_URL}/assets?assetIds=${tronAssetId}&includeIconUrl=true&includeRwaData=true`,
);

expect(result).toStrictEqual({
symbol: 'HTX',
decimals: 18,
name: 'HTX DAO',
image:
'https://static.cx.metamask.io/api/v2/tokenIcons/assets/tron/728126428/trc20/TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6.png',
assetId: tronAssetId,
address: tronAddress,
chainId: tronChainId,
rwaData: undefined,
});
});

it('should handle CAIP chain IDs', async () => {
const mockMetadata = {
assetId: mockAssetId,
Expand Down
5 changes: 5 additions & 0 deletions app/components/UI/Bridge/hooks/useAssetMetadata/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
formatAddressToCaipReference,
formatChainIdToHex,
isNonEvmChainId,
isTronChainId,
} from '@metamask/bridge-controller';
import { TokenRwaData } from '@metamask/assets-controllers';

Expand Down Expand Up @@ -54,6 +55,10 @@ export const toAssetId = (
return address;
} else if (chainId === MultichainNetwork.Solana) {
return CaipAssetTypeStruct.create(`${chainId}/token:${address}`);
} else if (isTronChainId(chainId)) {
// TRC-20 assets (base58 address) — matches the bridge-controller's
// `formatAddressToAssetId` encoding for Tron.
return CaipAssetTypeStruct.create(`${chainId}/trc20:${address}`);
}
// EVM assets
if (!isStrictHexString(address)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,41 @@ describe('AssetDetailsQuickBuy', () => {
);
});

it('forwards host token metadata (decimals + image) into the target', () => {
// Arrange — a Tron TRC-20 asset as it appears on the asset-details page:
// CAIP chain id and CAIP-19 address, with metadata already resolved.
mockFormatChainIdToCaip.mockImplementation((chainId: string) => chainId);
const tronToken = {
address: 'tron:728126428/trc20:TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6',
symbol: 'HTX',
name: 'HTX DAO',
chainId: 'tron:728126428',
decimals: 18,
image: 'https://example.com/htx.png',
} as unknown as TokenDetailsRouteParams;

// Act
render(
<AssetDetailsQuickBuy isVisible token={tronToken} onClose={jest.fn()} />,
);

// Assert — decimals/image ride along so QuickBuy can build the dest
// token without a metadata fetch.
expect(mockQuickBuyRoot).toHaveBeenCalledWith(
expect.objectContaining({
target: {
tokenAddress:
'tron:728126428/trc20:TUPM7K8REVzD2UdV4R5fe5M8XbnR2DdoJ6',
tokenSymbol: 'HTX',
tokenName: 'HTX DAO',
chain: 'tron:728126428',
tokenDecimals: 18,
tokenImage: 'https://example.com/htx.png',
},
}),
);
});

it('passes null target when token has no address', () => {
const tokenNoAddress = {
symbol: 'TKN',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ const AssetDetailsQuickBuy: React.FC<AssetDetailsQuickBuyProps> = ({
const tokenAddress = token?.address;
const tokenSymbol = token?.symbol;
const tokenName = token?.name;
const tokenDecimals = token?.decimals;
const tokenImage = token?.image;

const target = useMemo<QuickBuyTarget | null>(() => {
if (!chainId || !tokenAddress) {
Expand All @@ -54,8 +56,20 @@ const AssetDetailsQuickBuy: React.FC<AssetDetailsQuickBuyProps> = ({
tokenSymbol: tokenSymbol ?? '',
tokenName: tokenName ?? tokenSymbol ?? '',
chain,
// The asset page already holds the fully-resolved token; passing its
// metadata lets QuickBuy build the dest token without a metadata fetch
// (required for chains the metadata lookup can't validate, e.g. Tron).
tokenDecimals,
tokenImage,
};
}, [chainId, tokenAddress, tokenSymbol, tokenName]);
}, [
chainId,
tokenAddress,
tokenSymbol,
tokenName,
tokenDecimals,
tokenImage,
]);

const analyticsContext: QuickBuyAnalyticsContext = { source };

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,21 @@ const AnimatedScrollView = Animated.createAnimatedComponent(
*/
const QuickBuyAmountScreen: React.FC = () => {
const tw = useTailwind();
const { isUnsupportedChain } = useQuickBuyContext();
const { isUnsupportedChain, isDestTokenUnavailable } = useQuickBuyContext();

if (isUnsupportedChain) {
// Both states are terminal for this sheet: without a supported chain and a
// resolved destination token, quotes can never be fetched. Replace the buy
// flow with an explicit message instead of leaving the Buy button silently
// disabled with no feedback (TSA-659).
if (isUnsupportedChain || isDestTokenUnavailable) {
return (
<Box twClassName="px-4 py-8" alignItems={BoxAlignItems.Center}>
<Text variant={TextVariant.BodyMd} color={TextColor.TextAlternative}>
{strings('social_leaderboard.quick_buy.unsupported_chain')}
{strings(
isUnsupportedChain
? 'social_leaderboard.quick_buy.unsupported_chain'
: 'social_leaderboard.quick_buy.token_unavailable',
)}
</Text>
</Box>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const buildController = (
destToken: undefined,
isSetupLoading: false,
isUnsupportedChain: false,
isDestTokenUnavailable: false,
sourceToken: undefined,
sourceChainId: '0x1',
sourceTokenOptions: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ jest.mock('../../../../../../../locales/i18n', () => ({
jest.mock('@metamask/bridge-controller', () => ({
formatChainIdToHex: () => '0x1',
isNonEvmChainId: () => false,
isTronChainId: () => false,
isNativeAddress: () => false,
getNativeAssetForChainId: () => undefined,
}));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ const buildHookResult = (
destToken: undefined,
isSetupLoading: false,
isUnsupportedChain: false,
isDestTokenUnavailable: false,
sourceToken: undefined,
sourceChainId: '0x1',
sourceTokenOptions: [],
Expand Down Expand Up @@ -335,6 +336,33 @@ describe('QuickBuyRoot', () => {
expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen();
});

it('shows token unavailable message without amount flow when the dest token cannot be resolved', () => {
// Arrange — setup settled on a supported chain but no dest token resolved
// (e.g. metadata lookup returned nothing for the asset).
(useQuickBuyController as jest.Mock).mockReturnValue(
buildHookResult({ isDestTokenUnavailable: true }),
);

// Act
renderWithProvider(
<QuickBuyRoot
isVisible
target={positionToQuickBuyTarget(createPosition())}
features={TOP_TRADERS_QUICK_BUY_FEATURES}
onClose={jest.fn()}
/>,
);
act(() => {
storedOnOpenCallback?.();
});

// Assert
expect(
screen.getByText('social_leaderboard.quick_buy.token_unavailable'),
).toBeOnTheScreen();
expect(screen.queryByTestId('mock-amount-section')).not.toBeOnTheScreen();
});

it('renders nothing when isVisible is false', () => {
const { toJSON } = renderWithProvider(
<QuickBuyRoot
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ const buildHookResult = (
destToken: undefined,
isSetupLoading: false,
isUnsupportedChain: false,
isDestTokenUnavailable: false,
sourceToken: undefined,
sourceChainId: '0x1',
sourceTokenOptions: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ export interface UseQuickBuyControllerResult {
destToken: BridgeToken | undefined;
isSetupLoading: boolean;
isUnsupportedChain: boolean;
/** True when setup settled without resolving a destination token (see useQuickBuySetup). */
isDestTokenUnavailable: boolean;
// source token
sourceToken: BridgeToken | undefined;
sourceChainId: Hex | undefined;
Expand Down Expand Up @@ -276,6 +278,7 @@ export function useQuickBuyController(
destToken: positionTokenFromSetup,
isLoading: isSetupLoading,
isUnsupportedChain,
isDestTokenUnavailable,
} = useQuickBuySetup(target);

// ─── Buy "Pay with" options (tokens the user holds) ─────────────────────
Expand Down Expand Up @@ -1256,6 +1259,7 @@ export function useQuickBuyController(
destToken,
isSetupLoading,
isUnsupportedChain,
isDestTokenUnavailable,
sourceToken,
sourceChainId,
sourceTokenOptions,
Expand Down
Loading
Loading