diff --git a/test/e2e/flask/solana/check-balance.spec.ts b/test/e2e/flask/solana/check-balance.spec.ts
index 266c33b6de53..9a1633cfdbee 100644
--- a/test/e2e/flask/solana/check-balance.spec.ts
+++ b/test/e2e/flask/solana/check-balance.spec.ts
@@ -6,7 +6,11 @@ describe('Check balance', function (this: Suite) {
this.timeout(300000);
it('Just created Solana account shows 0 SOL when native token is enabled', async function () {
await withSolanaAccountSnap(
- { title: this.test?.fullTitle(), showNativeTokenAsMainBalance: true },
+ {
+ title: this.test?.fullTitle(),
+ solanaSupportEnabled: true,
+ showNativeTokenAsMainBalance: true,
+ },
async (driver) => {
await driver.refresh();
const homePage = new NonEvmHomepage(driver);
diff --git a/ui/components/app/multichain-transaction-details-modal/helpers.ts b/ui/components/app/multichain-transaction-details-modal/helpers.ts
new file mode 100644
index 000000000000..4fcfaf5cfaee
--- /dev/null
+++ b/ui/components/app/multichain-transaction-details-modal/helpers.ts
@@ -0,0 +1,110 @@
+import { DateTime } from 'luxon';
+import {
+ MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP,
+ MultichainNetworks,
+} from '../../../../shared/constants/multichain/networks';
+import {
+ formatDateWithYearContext,
+ shortenAddress,
+} from '../../../helpers/utils/util';
+
+/**
+ * Creates a transaction URL for block explorer based on network type
+ * Different networks have different URL patterns:
+ * Bitcoin Mainnet: https://blockstream.info/tx/{txId}
+ * Bitcoin Testnet: https://blockstream.info/testnet/tx/{txId}
+ * Solana Mainnet: https://explorer.solana.com/tx/{txId}
+ * Solana Devnet: https://explorer.solana.com/tx/{txId}?cluster=devnet
+ *
+ * @param txId - Transaction ID
+ * @param chainId - Network chain ID
+ * @returns Full URL to transaction in block explorer, or empty string if no explorer URL
+ */
+export const getTransactionUrl = (txId: string, chainId: string): string => {
+ const explorerBaseUrl =
+ MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[chainId as MultichainNetworks];
+ if (!explorerBaseUrl) {
+ return '';
+ }
+
+ // Change address URL to transaction URL for Bitcoin
+ if (chainId.startsWith('bip122:')) {
+ return `${explorerBaseUrl.replace('/address', '/tx')}/${txId}`;
+ }
+
+ const baseUrl = explorerBaseUrl.split('?')[0];
+ if (chainId === MultichainNetworks.SOLANA) {
+ return `${baseUrl}tx/${txId}`;
+ }
+ if (chainId === MultichainNetworks.SOLANA_DEVNET) {
+ return `${baseUrl}tx/${txId}?cluster=devnet`;
+ }
+
+ return '';
+};
+
+/**
+ * Creates an address URL for block explorer based on network type
+ * Different networks have different URL patterns:
+ * Bitcoin Mainnet: https://blockstream.info/address/{address}
+ * Bitcoin Testnet: https://blockstream.info/testnet/address/{address}
+ * Solana Mainnet: https://explorer.solana.com/address/{address}
+ * Solana Devnet: https://explorer.solana.com/address/{address}?cluster=devnet
+ *
+ * @param address - Wallet address
+ * @param chainId - Network chain ID
+ * @returns Full URL to address in block explorer, or empty string if no explorer URL
+ */
+export const getAddressUrl = (address: string, chainId: string): string => {
+ const explorerBaseUrl =
+ MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[chainId as MultichainNetworks];
+
+ if (!explorerBaseUrl) {
+ return '';
+ }
+
+ const baseUrl = explorerBaseUrl.split('?')[0];
+ if (chainId === MultichainNetworks.SOLANA) {
+ return `${baseUrl}address/${address}`;
+ }
+ if (chainId === MultichainNetworks.SOLANA_DEVNET) {
+ return `${baseUrl}address/${address}?cluster=devnet`;
+ }
+
+ // Bitcoin networks already have the correct address URL format
+ return `${explorerBaseUrl}/${address}`;
+};
+
+/**
+ * Formats a timestamp into a localized date and time string
+ * Example outputs: "Mar 15, 2024, 14:30" or "Dec 25, 2023, 09:45"
+ *
+ * @param timestamp - Unix timestamp in milliseconds
+ * @returns Formatted date and time string, or empty string if timestamp is null
+ */
+export const formatTimestamp = (timestamp: number | null) => {
+ if (!timestamp) {
+ return '';
+ }
+
+ // It's typical for Solana timestamps to use seconds, while JS Dates and most EVM chains use milliseconds.
+ // Hence we needed to use the conversion `timestamp < 1e12 ? timestamp * 1000 : timestamp` for it to work.
+ const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp;
+
+ const dateTime = DateTime.fromMillis(timestampMs);
+ const date = formatDateWithYearContext(timestampMs, 'MMM d, y', 'MMM d');
+ const time = dateTime.toFormat('HH:mm');
+
+ return `${date}, ${time}`;
+};
+
+/**
+ * Formats a shorten version of a transaction ID.
+ *
+ * @param txId - Transaction ID.
+ * @returns Formatted transaction ID.
+ */
+export function shortenTransactionId(txId: string) {
+ // For transactions we use a similar output for now, but shortenTransactionId will be added later.
+ return shortenAddress(txId);
+}
diff --git a/ui/components/app/multichain-transaction-details-modal/index.ts b/ui/components/app/multichain-transaction-details-modal/index.ts
new file mode 100644
index 000000000000..382cea3f4d14
--- /dev/null
+++ b/ui/components/app/multichain-transaction-details-modal/index.ts
@@ -0,0 +1 @@
+export { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
diff --git a/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.stories.tsx b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.stories.tsx
new file mode 100644
index 000000000000..395393ce0f4f
--- /dev/null
+++ b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.stories.tsx
@@ -0,0 +1,51 @@
+import { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
+
+export default {
+ title: 'Components/App/MultichainTransactionDetailsModal',
+ component: MultichainTransactionDetailsModal,
+};
+
+const mockTransaction = {
+ type: 'Send BTC',
+ status: 'Confirmed',
+ timestamp: new Date('Sep 30 2023 12:56').getTime(),
+ id: 'b93ea2cb4eed0f9e13284ed8860bcfc45de2488bb6a8b0b2a843c4b2fbce40f3',
+ from: [{
+ address: "bc1p7atgm33ak04ntsq9366mvym42ecrk4y34ssysc99340a39eq9arq0pu9uj",
+ asset: {
+ amount: '1.2',
+ unit: 'BTC',
+ }
+ }],
+ to: [{
+ address: "bc1p3t7744qewy262ym5afgeuqlwswtpfe22y7c4lwv0a7972p2k73msee7rr3",
+ asset: {
+ amount: '1.2',
+ unit: 'BTC',
+ }
+ }],
+ fees: [{
+ type: 'base',
+ asset: {
+ amount: '1.0001',
+ unit: 'BTC',
+ }
+ }]
+};
+
+export const Default = {
+ args: {
+ transaction: mockTransaction,
+ onClose: () => console.log('Modal closed'),
+ addressLink: 'https://explorer.bitcoin.com/btc/tx/3302...90c1',
+ multichainNetwork: {
+ nickname: 'Bitcoin',
+ isEvmNetwork: false,
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ network: {
+ chainId: 'bip122:000000000019d6689c085ae165831e93',
+ ticker: 'BTC',
+ },
+ },
+ },
+};
diff --git a/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.test.tsx b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.test.tsx
new file mode 100644
index 000000000000..439cff4f907e
--- /dev/null
+++ b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.test.tsx
@@ -0,0 +1,201 @@
+import React from 'react';
+import { CaipChainId } from '@metamask/utils';
+import { CaipAssetType, TransactionStatus } from '@metamask/keyring-api';
+import { screen, fireEvent } from '@testing-library/react';
+import { shortenAddress } from '../../../helpers/utils/util';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import { renderWithProvider } from '../../../../test/lib/render-helpers';
+import { MetaMetricsContext } from '../../../contexts/metametrics';
+import { MultichainNetworks } from '../../../../shared/constants/multichain/networks';
+import { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
+import { getTransactionUrl } from './helpers';
+
+jest.mock('../../../hooks/useI18nContext', () => ({
+ useI18nContext: jest.fn(),
+}));
+
+const mockTransaction = {
+ type: 'send' as const,
+ status: TransactionStatus.Confirmed as TransactionStatus,
+ timestamp: new Date('2023-09-30T12:56:00').getTime(),
+ id: 'b93ea2cb4eed0f9e13284ed8860bcfc45de2488bb6a8b0b2a843c4b2fbce40f3',
+ chain: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
+ account: 'test-account-id',
+ events: [],
+ from: [
+ {
+ address: 'bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2',
+ asset: {
+ fungible: true as const,
+ type: 'native' as CaipAssetType,
+ amount: '1.2',
+ unit: 'BTC',
+ },
+ },
+ ],
+ to: [
+ {
+ address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
+ asset: {
+ fungible: true as const,
+ type: 'native' as CaipAssetType,
+ amount: '1.2',
+ unit: 'BTC',
+ },
+ },
+ ],
+ fees: [
+ {
+ type: 'base' as const,
+ asset: {
+ fungible: true as const,
+ type: 'native' as CaipAssetType,
+ amount: '1.0001',
+ unit: 'BTC',
+ },
+ },
+ ],
+};
+
+const mockProps = {
+ transaction: mockTransaction,
+ onClose: jest.fn(),
+ addressLink: 'https://explorer.bitcoin.com/btc/tx/3302...90c1',
+ multichainNetwork: {
+ nickname: 'Bitcoin',
+ isEvmNetwork: false,
+ chainId: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
+ network: {
+ type: 'bitcoin',
+ chainId: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
+ ticker: 'BTC',
+ nickname: 'Bitcoin',
+ isAddressCompatible: (_address: string) => true,
+ rpcPrefs: {
+ blockExplorerUrl: 'https://explorer.bitcoin.com',
+ },
+ },
+ },
+};
+
+describe('MultichainTransactionDetailsModal', () => {
+ const mockTrackEvent = jest.fn();
+ const useI18nContextMock = useI18nContext as jest.Mock;
+
+ beforeEach(() => {
+ useI18nContextMock.mockReturnValue((key: string) => key);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ const renderComponent = (props = mockProps) => {
+ return renderWithProvider(
+
+
+ ,
+ );
+ };
+
+ it('renders the modal with transaction details', () => {
+ renderComponent();
+
+ expect(screen.getByText('Send')).toBeInTheDocument();
+ expect(screen.getByText('Confirmed')).toBeInTheDocument();
+ expect(screen.getByTestId('transaction-amount')).toHaveTextContent(
+ '1.2 BTC',
+ );
+ });
+
+ it('displays the correct transaction status with appropriate color', () => {
+ renderComponent();
+ const statusElement = screen.getByText('Confirmed');
+ expect(statusElement).toHaveClass('mm-box--color-success-default');
+ });
+
+ it('shows transaction ID in shortened format', () => {
+ renderComponent();
+ const txId = mockTransaction.id;
+ const shortenedTxId = screen.getByText(shortenAddress(txId));
+ expect(shortenedTxId).toBeInTheDocument();
+ });
+
+ it('displays network fee when present', () => {
+ renderComponent();
+ expect(screen.getByText('1.0001 BTC')).toBeInTheDocument();
+ });
+
+ it('calls onClose when close button is clicked', () => {
+ renderComponent();
+ const closeButton = screen.getByRole('button', { name: /close/iu });
+ fireEvent.click(closeButton);
+ expect(mockProps.onClose).toHaveBeenCalled();
+ });
+
+ it('renders the view details button with correct link', () => {
+ renderComponent();
+ const viewDetailsButton = screen.getByText('viewDetails');
+ expect(viewDetailsButton).toBeInTheDocument();
+ fireEvent.click(viewDetailsButton);
+ expect(mockTrackEvent).toHaveBeenCalled();
+ });
+
+ // @ts-expect-error This is missing from the Mocha type definitions
+ it.each(['confirmed', 'pending', 'failed'] as const)(
+ 'handles different transaction status: %s',
+ (status: string) => {
+ const propsWithStatus = {
+ ...mockProps,
+ transaction: {
+ ...mockTransaction,
+ status: status as TransactionStatus,
+ },
+ };
+ renderComponent(propsWithStatus);
+ expect(
+ screen.getByText(status.charAt(0).toUpperCase() + status.slice(1)),
+ ).toBeInTheDocument();
+ },
+ );
+
+ it('returns correct Bitcoin mainnet transaction URL', () => {
+ const txId =
+ '447755f24ab40f469309f357cfdd9e375e9569b2cf68aaeba2ebcc232eac9568';
+ const chainId = MultichainNetworks.BITCOIN;
+
+ expect(getTransactionUrl(txId, chainId)).toBe(
+ `https://blockstream.info/tx/${txId}`,
+ );
+ });
+
+ it('returns correct Bitcoin testnet transaction URL', () => {
+ const txId =
+ '447755f24ab40f469309f357cfdd9e375e9569b2cf68aaeba2ebcc232eac9568';
+ const chainId = MultichainNetworks.BITCOIN_TESTNET;
+
+ expect(getTransactionUrl(txId, chainId)).toBe(
+ `https://blockstream.info/testnet/tx/${txId}`,
+ );
+ });
+
+ it('returns correct Solana mainnet transaction URL', () => {
+ const txId =
+ '5Y64J6gUNd67hM63Aeks3qVLGWRM3A52PFFjqKSPTVDdAZFbaPDHHLTFCs3ioeFcAAXFmqcUftZeLJVZCzqovAJ4';
+ const chainId = MultichainNetworks.SOLANA;
+
+ expect(getTransactionUrl(txId, chainId)).toBe(
+ `https://explorer.solana.com/tx/${txId}`,
+ );
+ });
+
+ it('returns correct Solana devnet transaction URL', () => {
+ const txId =
+ '5Y64J6gUNd67hM63Aeks3qVLGWRM3A52PFFjqKSPTVDdAZFbaPDHHLTFCs3ioeFcAAXFmqcUftZeLJVZCzqovAJ4';
+ const chainId = MultichainNetworks.SOLANA_DEVNET;
+
+ expect(getTransactionUrl(txId, chainId)).toBe(
+ `https://explorer.solana.com/tx/${txId}?cluster=devnet`,
+ );
+ });
+});
diff --git a/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.tsx b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.tsx
new file mode 100644
index 000000000000..722493d12a24
--- /dev/null
+++ b/ui/components/app/multichain-transaction-details-modal/multichain-transaction-details-modal.tsx
@@ -0,0 +1,347 @@
+import React, { useContext } from 'react';
+import { capitalize } from 'lodash';
+import { Transaction, TransactionStatus } from '@metamask/keyring-api';
+import {
+ Display,
+ FlexDirection,
+ AlignItems,
+ JustifyContent,
+ TextVariant,
+ IconColor,
+ FontWeight,
+ TextColor,
+ TextAlign,
+} from '../../../helpers/constants/design-system';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import {
+ ModalOverlay,
+ ModalContent,
+ ModalHeader,
+ Modal,
+ Box,
+ Text,
+ ModalFooter,
+ Button,
+ IconName,
+ ButtonVariant,
+ Icon,
+ IconSize,
+ ButtonSize,
+ ButtonLink,
+ ButtonLinkSize,
+} from '../../component-library';
+import { MetaMetricsContext } from '../../../contexts/metametrics';
+import { openBlockExplorer } from '../../multichain/menu-items/view-explorer-menu-item';
+import { ConfirmInfoRowDivider as Divider } from '../confirm/info/row';
+import { shortenAddress } from '../../../helpers/utils/util';
+import {
+ formatTimestamp,
+ getTransactionUrl,
+ getAddressUrl,
+ shortenTransactionId,
+} from './helpers';
+
+export type MultichainTransactionDetailsModalProps = {
+ transaction: Transaction;
+ onClose: () => void;
+ addressLink: string;
+};
+
+export function MultichainTransactionDetailsModal({
+ transaction,
+ onClose,
+ addressLink,
+}: MultichainTransactionDetailsModalProps) {
+ const t = useI18nContext();
+ const trackEvent = useContext(MetaMetricsContext);
+
+ const getStatusColor = (status: string) => {
+ switch (status.toLowerCase()) {
+ case TransactionStatus.Confirmed:
+ return TextColor.successDefault;
+ case TransactionStatus.Unconfirmed:
+ return TextColor.warningDefault;
+ case TransactionStatus.Failed:
+ return TextColor.errorDefault;
+ default:
+ return TextColor.textDefault;
+ }
+ };
+
+ const getAssetDisplay = (asset: typeof fromAsset) => {
+ if (!asset) {
+ return null;
+ }
+ if (asset.fungible === true) {
+ return `${asset.amount} ${asset.unit}`;
+ }
+ if (asset.fungible === false) {
+ return asset.id;
+ }
+ return null;
+ };
+
+ if (!transaction?.from?.[0] || !transaction?.to?.[0]) {
+ return null;
+ }
+
+ // We only support 1 recipient for "from" and "to" for now:
+ const {
+ id: txId,
+ from: [{ address: fromAddress, asset: fromAsset }],
+ to: [{ address: toAddress }],
+ fees: [{ asset: feeAsset }],
+ fees,
+ timestamp,
+ status,
+ chain,
+ type,
+ } = transaction;
+
+ return (
+
+
+
+
+
+ {capitalize(type)}
+
+
+ {formatTimestamp(timestamp)}
+
+
+
+
+
+
+
+
+ {/* Status */}
+
+
+ {t('status')}
+
+
+ {capitalize(status)}
+
+
+
+ {/* Transaction ID */}
+
+
+ {t('notificationItemTransactionId')}
+
+
+
+ {shortenTransactionId(txId)}
+
+ navigator.clipboard.writeText(
+ getTransactionUrl(txId, chain),
+ )
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+ {/* From */}
+
+
+ {t('from')}
+
+
+
+ {shortenAddress(fromAddress)}
+
+ navigator.clipboard.writeText(
+ getAddressUrl(fromAddress, chain),
+ )
+ }
+ />
+
+
+
+
+ {/* To */}
+
+
+ {t('to')}
+
+
+
+ {shortenAddress(toAddress)}
+
+ navigator.clipboard.writeText(
+ getAddressUrl(toAddress, chain),
+ )
+ }
+ />
+
+
+
+
+ {/* Amount */}
+
+
+ {t('amount')}
+
+
+
+ {getAssetDisplay(fromAsset)}
+
+
+
+
+ {/* Network Fee */}
+ {fees?.length > 0 && (
+
+
+ {t('networkFee')}
+
+
+
+ {getAssetDisplay(feeAsset)}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js
index bf38abd2c543..380e13ae437f 100644
--- a/ui/components/app/transaction-list/transaction-list.component.js
+++ b/ui/components/app/transaction-list/transaction-list.component.js
@@ -47,6 +47,8 @@ import {
///: BEGIN:ONLY_INCLUDE_IF(build-flask)
import TransactionIcon from '../transaction-icon';
import TransactionStatusLabel from '../transaction-status-label/transaction-status-label';
+import { MultichainTransactionDetailsModal } from '../multichain-transaction-details-modal';
+import { formatTimestamp } from '../multichain-transaction-details-modal/helpers';
///: END:ONLY_INCLUDE_IF
import {
@@ -193,6 +195,8 @@ export default function TransactionList({
const t = useI18nContext();
///: BEGIN:ONLY_INCLUDE_IF(build-flask)
+ const [selectedTransaction, setSelectedTransaction] = useState(null);
+
const nonEvmTransactions = useSelector(
getSelectedAccountMultichainTransactions,
);
@@ -319,6 +323,10 @@ export default function TransactionList({
}, []);
///: BEGIN:ONLY_INCLUDE_IF(build-flask)
+ const toggleShowDetails = useCallback((transaction = null) => {
+ setSelectedTransaction(transaction);
+ }, []);
+
const multichainNetwork = useMultichainSelector(
getMultichainNetwork,
selectedAccount,
@@ -334,119 +342,133 @@ export default function TransactionList({
const metricsLocation = 'Activity Tab';
return (
-
- {/* TODO: Non-EVM transactions are not paginated for now. */}
-
- {nonEvmTransactions?.transactions.length > 0 ? (
-
- {groupNonEvmTransactionsByDate(nonEvmTransactions).map(
- (dateGroup) => (
-
-
- {dateGroup.date}
-
- {dateGroup.transactionGroups.map((transaction, index) => (
-
+ {selectedTransaction && (
+ toggleShowDetails(null)}
+ addressLink={addressLink}
+ />
+ )}
+
+
+ {/* TODO: Non-EVM transactions are not paginated for now. */}
+
+ {nonEvmTransactions?.transactions.length > 0 ? (
+
+ {groupNonEvmTransactionsByDate(nonEvmTransactions).map(
+ (dateGroup) => (
+
+
+ {dateGroup.date}
+
+ {dateGroup.transactionGroups.map((transaction, index) => (
+ toggleShowDetails(transaction)}
+ icon={
+
+ }
+ display="block"
+ positionObj={{ right: -4, top: -4 }}
+ >
+
- }
- display="block"
- positionObj={{ right: -4, top: -4 }}
- >
-
+ }
+ rightContent={
+ <>
+
+ {transaction.from?.[0]?.asset?.amount &&
+ transaction.from[0]?.asset?.unit
+ ? `${transaction.from[0].asset.amount} ${transaction.from[0].asset.unit}`
+ : ''}
+
+ >
+ }
+ subtitle={
+
-
- }
- rightContent={
- <>
-
- {`${transaction.from[0]?.asset?.amount} ${transaction.from[0]?.asset?.unit}`}
-
- >
- }
- subtitle={
-
- }
- title={capitalize(transaction.type)}
- >
- ))}
-
- ),
- )}
-
-
+ }
+ title={capitalize(transaction.type)}
+ >
+ ))}
+
+ ),
+ )}
+
+
+
-
- ) : (
-
-
- {t('noTransactions')}
+ ) : (
+
+
+ {t('noTransactions')}
+
-
- )}
+ )}
+
-
+ >
);
}
///: END:ONLY_INCLUDE_IF