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