diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 0ff1a58fb62b..ab1e3e300740 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -4372,6 +4372,9 @@ "recoveryPhraseReminderTitle": { "message": "Protect your funds" }, + "redeposit": { + "message": "Redeposit" + }, "refreshList": { "message": "Refresh list" }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 4ecab44a1a2f..96d15277911e 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -4372,6 +4372,9 @@ "recoveryPhraseReminderTitle": { "message": "Protect your funds" }, + "redeposit": { + "message": "Redeposit" + }, "refreshList": { "message": "Refresh list" }, diff --git a/app/images/bitcoin-testnet-logo.svg b/app/images/bitcoin-testnet-logo.svg new file mode 100644 index 000000000000..336e8600e893 --- /dev/null +++ b/app/images/bitcoin-testnet-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/shared/constants/multichain/networks.ts b/shared/constants/multichain/networks.ts index acecbcebbeaf..ee1b7c0907da 100644 --- a/shared/constants/multichain/networks.ts +++ b/shared/constants/multichain/networks.ts @@ -28,6 +28,7 @@ export type MultichainProviderConfig = ProviderConfigWithImageUrl & { // NOTE: For now we use a callback to check if the address is compatible with // the given network or not isAddressCompatible: (address: string) => boolean; + decimals: number; }; export type MultichainNetworkIds = `${MultichainNetworks}`; @@ -55,6 +56,8 @@ export const MULTICHAIN_NETWORK_TO_NICKNAME: Record = { }; export const BITCOIN_TOKEN_IMAGE_URL = './images/bitcoin-logo.svg'; +export const BITCOIN_TESTNET_TOKEN_IMAGE_URL = + './images/bitcoin-testnet-logo.svg'; export const SOLANA_TOKEN_IMAGE_URL = './images/solana-logo.svg'; export const BITCOIN_BLOCK_EXPLORER_URL = 'https://mempool.space'; @@ -94,6 +97,7 @@ export const MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP: Record< export const MULTICHAIN_TOKEN_IMAGE_MAP: Record = { [MultichainNetworks.BITCOIN]: BITCOIN_TOKEN_IMAGE_URL, + [MultichainNetworks.BITCOIN_TESTNET]: BITCOIN_TESTNET_TOKEN_IMAGE_URL, [MultichainNetworks.SOLANA]: SOLANA_TOKEN_IMAGE_URL, } as const; @@ -111,6 +115,7 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< nickname: 'Bitcoin', id: 'btc-mainnet', type: 'rpc', + decimals: 8, rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN], blockExplorerUrl: @@ -131,8 +136,9 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< nickname: 'Bitcoin (testnet)', id: 'btc-testnet', type: 'rpc', + decimals: 8, rpcPrefs: { - imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN], + imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.BITCOIN_TESTNET], blockExplorerUrl: MULTICHAIN_NETWORK_BLOCK_EXPLORER_FORMAT_URLS_MAP[ MultichainNetworks.BITCOIN_TESTNET @@ -154,6 +160,7 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< nickname: 'Solana', id: 'solana-mainnet', type: 'rpc', + decimals: 5, rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: @@ -174,6 +181,7 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< nickname: 'Solana (devnet)', id: 'solana-devnet', type: 'rpc', + decimals: 5, rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: @@ -194,6 +202,7 @@ export const MULTICHAIN_PROVIDER_CONFIGS: Record< nickname: 'Solana (testnet)', id: 'solana-testnet', type: 'rpc', + decimals: 5, rpcPrefs: { imageUrl: MULTICHAIN_TOKEN_IMAGE_MAP[MultichainNetworks.SOLANA], blockExplorerUrl: diff --git a/shared/constants/transaction.ts b/shared/constants/transaction.ts index ac4bb77b070a..f95e8d119226 100644 --- a/shared/constants/transaction.ts +++ b/shared/constants/transaction.ts @@ -118,6 +118,10 @@ export enum TransactionGroupCategory { * where the final token is sent to another chain. */ bridge = 'bridge', + /** + * Transaction group representing a redeposit (a send to ourselves), mainly used for consolidation. + */ + redeposit = 'redeposit', } /** 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 index 9279233e6123..63ee12422e81 100644 --- 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 @@ -6,45 +6,57 @@ export default { }; const mockTransaction = { - type: 'Send BTC', - status: 'Confirmed', + type: 'send', + 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', - } - }] + chain: 'bip122:000000000019d6689c085ae165831e93', + account: 'test-account-id', + from: [ + { + address: 'bc1p7atgm33ak04ntsq9366mvym42ecrk4y34ssysc99340a39eq9arq0pu9uj', + asset: { + amount: '1.2', + unit: 'BTC', + fungible: true, + }, + }, + ], + to: [ + { + address: 'bc1p3t7744qewy262ym5afgeuqlwswtpfe22y7c4lwv0a7972p2k73msee7rr3', + asset: { + amount: '1.2', + unit: 'BTC', + fungible: true, + }, + }, + ], + fees: [ + { + type: 'priority', + asset: { + amount: '1.0001', + unit: 'BTC', + fungible: true, + }, + }, + ], }; export const Default = { args: { transaction: mockTransaction, onClose: () => console.log('Modal closed'), - multichainNetwork: { + userAddress: + 'bc1p7atgm33ak04ntsq9366mvym42ecrk4y34ssysc99340a39eq9arq0pu9uj', + networkConfig: { nickname: 'Bitcoin', isEvmNetwork: false, chainId: 'bip122:000000000019d6689c085ae165831e93', - network: { - chainId: 'bip122:000000000019d6689c085ae165831e93', - ticker: 'BTC', - }, + decimals: 8, + ticker: 'BTC', + id: 'btc-mainnet', }, }, }; 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 index b3fd120540b1..b4436e9b0de4 100644 --- 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 @@ -11,7 +11,9 @@ import { renderWithProvider } from '../../../../test/lib/render-helpers'; import { MOCK_ACCOUNT_SOLANA_MAINNET } from '../../../../test/data/mock-accounts'; import { MetaMetricsContext } from '../../../contexts/metametrics'; import { + MULTICHAIN_PROVIDER_CONFIGS, MultichainNetworks, + MultichainProviderConfig, SOLANA_BLOCK_EXPLORER_URL, } from '../../../../shared/constants/multichain/networks'; import mockState from '../../../../test/data/mock-state.json'; @@ -117,6 +119,7 @@ const mockProps = { transaction: mockTransaction, onClose: jest.fn(), userAddress: MOCK_ACCOUNT_SOLANA_MAINNET.address, + networkConfig: MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.BITCOIN], }; describe('MultichainTransactionDetailsModal', () => { @@ -136,6 +139,7 @@ describe('MultichainTransactionDetailsModal', () => { transaction: Transaction; onClose: jest.Mock; userAddress: string; + networkConfig: MultichainProviderConfig; } = mockProps, ) => { const store = configureStore(mockState.metamask); @@ -299,13 +303,14 @@ describe('MultichainTransactionDetailsModal', () => { transaction: mockSwapTransaction, onClose: jest.fn(), userAddress, + networkConfig: MULTICHAIN_PROVIDER_CONFIGS[MultichainNetworks.SOLANA], }; renderComponent(swapProps); expect(screen.getByText('Swap')).toBeInTheDocument(); expect(screen.getByTestId('transaction-amount')).toHaveTextContent( - '2.5 SOL', + '100 USDC', ); const addressStart = userAddress.substring(0, 6); 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 index f941cad336f3..b0fbda32a5ab 100644 --- 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 @@ -1,6 +1,10 @@ import React, { useContext } from 'react'; import { capitalize } from 'lodash'; -import { Transaction, TransactionStatus } from '@metamask/keyring-api'; +import { + Transaction, + TransactionStatus, + TransactionType, +} from '@metamask/keyring-api'; import { Display, FlexDirection, @@ -42,6 +46,7 @@ import { KEYRING_TRANSACTION_STATUS_KEY, useMultichainTransactionDisplay, } from '../../../hooks/useMultichainTransactionDisplay'; +import { MultichainProviderConfig } from '../../../../shared/constants/multichain/networks'; import { formatTimestamp, getTransactionUrl, @@ -53,28 +58,20 @@ export type MultichainTransactionDetailsModalProps = { transaction: Transaction; onClose: () => void; userAddress: string; + networkConfig: MultichainProviderConfig; }; export function MultichainTransactionDetailsModal({ transaction, onClose, userAddress, + networkConfig, }: MultichainTransactionDetailsModalProps) { const t = useI18nContext(); const trackEvent = useContext(MetaMetricsContext); - const { - id, - type, - timestamp, - chain, - status, - from, - to, - baseFee, - priorityFee, - asset, - } = useMultichainTransactionDisplay({ transaction, userAddress }); + const { assetInputs, assetOutputs, isRedeposit, baseFee, priorityFee } = + useMultichainTransactionDisplay(transaction, networkConfig); const getStatusColor = (txStatus: string) => { switch (txStatus.toLowerCase()) { @@ -88,7 +85,68 @@ export function MultichainTransactionDetailsModal({ return TextColor.textDefault; } }; - const statusKey = KEYRING_TRANSACTION_STATUS_KEY[status]; + const statusKey = KEYRING_TRANSACTION_STATUS_KEY[transaction.status]; + + const accountComponent = (title: string, address?: string) => + address ? ( + + + {title} + + + + {shortenAddress(address)} + + navigator.clipboard.writeText( + getAddressUrl(address as string, transaction.chain), + ) + } + /> + + + + ) : null; + + const amountComponent = ( + { + amount, + unit, + }: { + amount: string; + unit: string; + }, + title: string, + dataTestId: string, + ) => ( + + + {title} + + + + {amount} {unit} + + + + ); return ( - {capitalize(type)} + {capitalize(isRedeposit ? t('redeposit') : transaction.type)} - {formatTimestamp(timestamp)} + {formatTimestamp(transaction.timestamp)} @@ -136,7 +194,10 @@ export function MultichainTransactionDetailsModal({ {t('status')} - + {capitalize(t(statusKey))} @@ -162,9 +223,9 @@ export function MultichainTransactionDetailsModal({ }} as="a" externalLink - href={getTransactionUrl(id, chain)} + href={getTransactionUrl(transaction.id, transaction.chain)} > - {shortenTransactionId(id)} + {shortenTransactionId(transaction.id)} navigator.clipboard.writeText( - getTransactionUrl(id, chain), + getTransactionUrl(transaction.id, transaction.chain), ) } /> @@ -191,172 +252,33 @@ export function MultichainTransactionDetailsModal({ gap={4} > {/* From */} - {from?.address && ( - - - {t('from')} - - - - {shortenAddress(from.address)} - - navigator.clipboard.writeText( - getAddressUrl(from.address as string, chain), - ) - } - /> - - - - )} + {transaction.type === TransactionType.Send + ? accountComponent(t('from'), userAddress) + : assetInputs.map((input) => + accountComponent(t('from'), input.address), + )} - {/* To */} - {to?.address && ( - - - {t('to')} - - - - {shortenAddress(to.address)} - - navigator.clipboard.writeText( - getAddressUrl(to.address as string, chain), - ) - } - /> - - - - )} + {/* Amounts per token */} + {assetOutputs.map((output) => ( + <> + {accountComponent(t('to'), output.address)} + {amountComponent(output, t('amount'), 'transaction-amount')} + + ))} - {/* Amount */} - {asset && ( - - - {t('amount')} - - - - {asset?.amount} {asset?.unit} - - - + {/* Base Fees */} + {baseFee.map((fee) => + amountComponent(fee, t('networkFee'), 'transaction-base-fee'), )} - {/* Network Fees */} - {baseFee ? ( - - - {t('networkFee')} - - - - {baseFee.amount} {baseFee.unit} - - - - ) : null} - - {priorityFee ? ( - - - {t('priorityFee')} - - - - {priorityFee.amount} {priorityFee.unit} - - - - ) : null} + {/* Priority Fees */} + {priorityFee.map((fee) => + amountComponent( + fee, + t('priorityFee'), + 'transaction-priority-fee', + ), + )} @@ -371,7 +293,7 @@ export function MultichainTransactionDetailsModal({ variant={ButtonVariant.Link} onClick={() => { global.platform.openTab({ - url: getTransactionUrl(id, chain), + url: getTransactionUrl(transaction.id, transaction.chain), }); trackEvent({ @@ -380,7 +302,9 @@ export function MultichainTransactionDetailsModal({ properties: { link_type: MetaMetricsEventLinkType.AccountTracker, location: 'Transaction Details', - url_domain: getURLHostName(getTransactionUrl(id, chain)), + url_domain: getURLHostName( + getTransactionUrl(transaction.id, transaction.chain), + ), }, }); }} diff --git a/ui/components/app/transaction-icon/transaction-icon.js b/ui/components/app/transaction-icon/transaction-icon.js index 6051862aae26..b1a23f7f9a39 100644 --- a/ui/components/app/transaction-icon/transaction-icon.js +++ b/ui/components/app/transaction-icon/transaction-icon.js @@ -22,6 +22,7 @@ const ICON_MAP = { [TransactionGroupCategory.swap]: IconName.SwapHorizontal, [TransactionGroupCategory.swapAndSend]: IconName.Arrow2UpRight, [TransactionGroupCategory.bridge]: IconName.Bridge, + [TransactionGroupCategory.redeposit]: IconName.Refresh, }; const COLOR_MAP = { diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js index be0cf22a657e..cc530da16f36 100644 --- a/ui/components/app/transaction-list/transaction-list.component.js +++ b/ui/components/app/transaction-list/transaction-list.component.js @@ -40,12 +40,7 @@ import { TOKEN_CATEGORY_HASH } from '../../../helpers/constants/transactions'; import { SWAPS_CHAINID_CONTRACT_ADDRESS_MAP } from '../../../../shared/constants/swaps'; import { isEqualCaseInsensitive } from '../../../../shared/modules/string-utils'; import { useMultichainSelector } from '../../../hooks/useMultichainSelector'; -import { - getSelectedInternalAccount, - ///: BEGIN:ONLY_INCLUDE_IF(multichain) - isSelectedInternalAccountSolana, - ///: END:ONLY_INCLUDE_IF -} from '../../../selectors/accounts'; +import { getSelectedInternalAccount } from '../../../selectors/accounts'; import { getMultichainNetwork, ///: BEGIN:ONLY_INCLUDE_IF(multichain) @@ -63,6 +58,8 @@ import { IconName, BadgeWrapper, AvatarNetwork, + AvatarNetworkSize, + BadgeWrapperAnchorElementShape, ///: END:ONLY_INCLUDE_IF } from '../../component-library'; ///: BEGIN:ONLY_INCLUDE_IF(multichain) @@ -73,6 +70,7 @@ import { formatTimestamp } from '../multichain-transaction-details-modal/helpers ///: END:ONLY_INCLUDE_IF import { ///: BEGIN:ONLY_INCLUDE_IF(multichain) + BackgroundColor, Display, ///: END:ONLY_INCLUDE_IF TextColor, @@ -92,16 +90,11 @@ import { openBlockExplorer } from '../../multichain/menu-items/view-explorer-men import { getMultichainAccountUrl } from '../../../helpers/utils/multichain/blockExplorer'; import { ActivityListItem } from '../../multichain'; import { MetaMetricsContext } from '../../../contexts/metametrics'; -import { - MULTICHAIN_PROVIDER_CONFIGS, - MultichainNetworks, - SOLANA_TOKEN_IMAGE_URL, - BITCOIN_TOKEN_IMAGE_URL, -} from '../../../../shared/constants/multichain/networks'; import { KEYRING_TRANSACTION_STATUS_KEY, useMultichainTransactionDisplay, } from '../../../hooks/useMultichainTransactionDisplay'; +import { TransactionGroupCategory } from '../../../../shared/constants/transaction'; ///: END:ONLY_INCLUDE_IF import { endTrace, TraceName } from '../../../../shared/lib/trace'; @@ -251,6 +244,7 @@ export default function TransactionList({ const isTokenNetworkFilterEqualCurrentNetwork = useSelector( getIsTokenNetworkFilterEqualCurrentNetwork, ); + const selectedAccount = useSelector(getSelectedAccount); ///: BEGIN:ONLY_INCLUDE_IF(multichain) const [selectedTransaction, setSelectedTransaction] = useState(null); @@ -305,7 +299,6 @@ export default function TransactionList({ ]); const chainId = useSelector(getCurrentChainId); - const selectedAccount = useSelector(getSelectedAccount); const account = useSelector(getSelectedInternalAccount); const { isEvmNetwork } = useMultichainSelector(getMultichainNetwork, account); @@ -482,6 +475,7 @@ export default function TransactionList({ transaction={selectedTransaction} onClose={() => toggleShowDetails(null)} userAddress={selectedAccount.address} + networkConfig={multichainNetwork.network} /> )} @@ -502,13 +496,12 @@ export default function TransactionList({ > {dateGroup.date} - {dateGroup.transactionGroups.map((transaction, index) => ( + {dateGroup.transactionGroups.map((transaction) => ( ))} @@ -659,61 +652,88 @@ export default function TransactionList({ ///: BEGIN:ONLY_INCLUDE_IF(multichain) const MultichainTransactionListItem = ({ transaction, - userAddress, + networkConfig, toggleShowDetails, }) => { const t = useI18nContext(); - const isSolanaAccount = useSelector(isSelectedInternalAccountSolana); - - const { type, status, to, from, asset } = useMultichainTransactionDisplay({ - transaction, - userAddress, - }); - - let title = capitalize(type); - const statusKey = KEYRING_TRANSACTION_STATUS_KEY[status]; - - if (type === TransactionType.swap) { - title = `${t('swap')} ${from.asset.unit} ${'to'} ${to.asset.unit}`; + const { assetInputs, assetOutputs, isRedeposit } = + useMultichainTransactionDisplay(transaction, networkConfig); + let title = capitalize(transaction.type); + const statusKey = KEYRING_TRANSACTION_STATUS_KEY[transaction.status]; + + // A redeposit transaction is a special case where the outputs list is emtpy because we are sending to ourselves and only pay the fees + // Mainly used for consolidation transactions + if (isRedeposit) { + return ( + toggleShowDetails(transaction)} + icon={ + + } + > + + + } + title={t('redeposit')} + // eslint-disable-next-line react/jsx-no-duplicate-props + subtitle={ + + } + /> + ); } - return ( - toggleShowDetails(transaction)} - icon={ - - } - display="block" - positionObj={{ right: -4, top: -4 }} - > - - - } - rightContent={ - <> + return assetOutputs.map((output, index) => { + if (transaction.type === TransactionType.swap) { + title = `${t('swap')} ${assetInputs[index].unit} ${'to'} ${output.unit}`; + } + + return ( + toggleShowDetails(transaction)} + icon={ + + } + > + + + } + rightContent={ - {asset?.amount} {asset?.unit} + {output.amount} {output.unit} - - } - title={transaction.isBridgeTx ? t('bridge') : title} - // eslint-disable-next-line react/jsx-no-duplicate-props - subtitle={ - transaction.isBridgeTx && transaction.bridgeInfo ? ( - <> + } + title={transaction.isBridgeTx ? t('bridge') : title} + // eslint-disable-next-line react/jsx-no-duplicate-props + subtitle={ + transaction.isBridgeTx && transaction.bridgeInfo ? ( + <> + + + {`${t('to')} ${transaction.bridgeInfo.destAsset?.symbol} ${t( + 'on', + )} ${ + // Use the pre-computed chain name from our hook, or fall back to chain ID + transaction.bridgeInfo.destChainName || + transaction.bridgeInfo.destChainId + }`} + + + ) : ( - - {`${t('to')} ${transaction.bridgeInfo.destAsset?.symbol} ${t( - 'on', - )} ${ - // Use the pre-computed chain name from our hook, or fall back to chain ID - transaction.bridgeInfo.destChainName || - transaction.bridgeInfo.destChainId - }`} - - - ) : ( - - ) - } - > - ); + ) + } + /> + ); + }); }; + MultichainTransactionListItem.propTypes = { transaction: PropTypes.object.isRequired, - userAddress: PropTypes.string.isRequired, + networkConfig: PropTypes.object.isRequired, toggleShowDetails: PropTypes.func.isRequired, }; diff --git a/ui/components/app/transaction-list/transaction-list.test.js b/ui/components/app/transaction-list/transaction-list.test.js index 8918a245ab39..6496fd3c149e 100644 --- a/ui/components/app/transaction-list/transaction-list.test.js +++ b/ui/components/app/transaction-list/transaction-list.test.js @@ -57,7 +57,17 @@ const btcState = { type: 'send', account: MOCK_ACCOUNT_BIP122_P2WPKH.id, from: [], - to: [], + to: [ + { + address: MOCK_ACCOUNT_BIP122_P2WPKH.address, + asset: { + fungible: true, + type: '', + unit: 'BTC', + amount: '0.000000723', + }, + }, + ], fees: [], events: [], }, @@ -93,15 +103,6 @@ const solanaSwapState = { status: 'confirmed', type: 'swap', from: [ - { - address: '8kR2HTHzPtTJuzpFZ8jtGCQ9TpahPaWbZfTNRs2GJdxq', - asset: { - fungible: true, - type: '', - unit: 'SOL', - amount: '0.000073111', - }, - }, { address: MOCK_ACCOUNT_SOLANA_MAINNET.address, asset: { @@ -111,44 +112,8 @@ const solanaSwapState = { amount: '0.01', }, }, - { - address: 'HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR', - asset: { - fungible: true, - type: '', - unit: 'BONK', - amount: '2583.728601', - }, - }, - { - address: '3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL', - asset: { - fungible: true, - type: '', - unit: 'SOL', - amount: '0.000073111', - }, - }, ], to: [ - { - address: 'CebN5WGQ4jvEPvsVU4EoHEpgzq1VV7AbicfhtW4xC9iM', - asset: { - fungible: true, - type: '', - unit: 'SOL', - amount: '0.000000723', - }, - }, - { - address: 'HUCjBnmd4FoUjCCMYQ9xFz1ce1r8vWAd8uMhUQakE2FR', - asset: { - fungible: true, - type: '', - unit: 'SOL', - amount: '0.00007238', - }, - }, { address: MOCK_ACCOUNT_SOLANA_MAINNET.address, asset: { @@ -158,15 +123,6 @@ const solanaSwapState = { amount: '2583.72', }, }, - { - address: '3msVd34R5KxonDzyNSV5nT19UtUeJ2RF1NaQhvVPNLxL', - asset: { - fungible: true, - type: '', - unit: 'SOL', - amount: '0.01', - }, - }, ], fees: [ { @@ -374,7 +330,7 @@ describe('TransactionList', () => { expect(getByTestId('activity-list-item')).toBeInTheDocument(); - expect(getByText('-0.01 SOL')).toBeInTheDocument(); + expect(getByText('2,583.72 BONK')).toBeInTheDocument(); const viewOnExplorerBtn = getByRole('button', { name: 'View on block explorer', diff --git a/ui/hooks/useMultichainTransactionDisplay.ts b/ui/hooks/useMultichainTransactionDisplay.ts index 7efc4d705c29..11d858ee5c87 100644 --- a/ui/hooks/useMultichainTransactionDisplay.ts +++ b/ui/hooks/useMultichainTransactionDisplay.ts @@ -8,9 +8,7 @@ import { useSelector } from 'react-redux'; import { formatWithThreshold } from '../components/app/assets/util/formatWithThreshold'; import { getIntlLocale } from '../ducks/locale/locale'; import { TransactionGroupStatus } from '../../shared/constants/transaction'; - -type Fee = Transaction['fees'][0]['asset']; -type Token = Transaction['from'][0]['asset']; +import { MultichainProviderConfig } from '../../shared/constants/multichain/networks'; export const KEYRING_TRANSACTION_STATUS_KEY = { [KeyringTransactionStatus.Failed]: TransactionStatus.failed, @@ -19,112 +17,109 @@ export const KEYRING_TRANSACTION_STATUS_KEY = { [KeyringTransactionStatus.Submitted]: TransactionStatus.submitted, }; -export function useMultichainTransactionDisplay({ - transaction, - userAddress, -}: { - transaction: Transaction; - userAddress: string; -}) { +type Asset = { + unit: string; + type: `${string}:${string}/${string}:${string}`; + amount: string; + fungible: true; +}; + +type Movement = { + asset: Asset; + address?: string; +}; + +export function useMultichainTransactionDisplay( + transaction: Transaction, + networkConfig: MultichainProviderConfig, +) { const locale = useSelector(getIntlLocale); + const isNegative = transaction.type === TransactionType.Send; - const transactionFromEntry = transaction.from?.find( - (entry) => entry?.address === userAddress, + const assetInputs = aggregateAmount( + transaction.from as Movement[], + isNegative, + locale, + networkConfig.decimals, ); - const transactionToEntry = transaction.to?.find( - (entry) => entry?.address === userAddress, + const assetOutputs = aggregateAmount( + transaction.to as Movement[], + isNegative, + locale, + networkConfig.decimals, + ); + const baseFee = aggregateAmount( + transaction.fees.filter((fee) => fee.type === 'base') as Movement[], + isNegative, + locale, + ); + const priorityFee = aggregateAmount( + transaction.fees.filter((fee) => fee.type === 'priority') as Movement[], + isNegative, + locale, ); - - const baseFee = transaction?.fees?.find((fee) => fee.type === 'base') ?? null; - const priorityFee = - transaction?.fees?.find((fee) => fee.type === 'priority') ?? null; - - let from = null; - let to = null; - - switch (transaction.type) { - case TransactionType.Swap: - from = transactionFromEntry ?? null; - to = transactionToEntry ?? null; - break; - case TransactionType.Send: - from = transactionFromEntry ?? transaction.from?.[0] ?? null; - to = transaction.to?.[0] ?? null; - break; - case TransactionType.Receive: - from = transaction.from?.[0] ?? null; - to = transactionToEntry ?? transaction.to?.[0] ?? null; - break; - default: - from = transaction.from?.[0] ?? null; - to = transaction.to?.[0] ?? null; - } - - const asset = { - [TransactionType.Send]: parseAssetWithThreshold( - from?.asset ?? null, - '0.00001', - { locale, isNegative: true }, - ), - [TransactionType.Receive]: parseAssetWithThreshold( - to?.asset ?? null, - '0.00001', - { locale, isNegative: false }, - ), - [TransactionType.Swap]: parseAssetWithThreshold( - from?.asset ?? null, - '0.00001', - { locale, isNegative: true }, - ), - }[transaction.type]; return { - ...transaction, - from, - to, - asset, - baseFee: parseAssetWithThreshold(baseFee?.asset ?? null, '0.0000001', { - locale, - isNegative: false, - }), - priorityFee: parseAssetWithThreshold( - priorityFee?.asset ?? null, - '0.0000001', - { locale, isNegative: false }, - ), + assetInputs, + assetOutputs, + baseFee, + priorityFee, + isRedeposit: assetOutputs.length === 0, }; } -function parseAssetWithThreshold( - asset: Token | Fee | null, - threshold: string, - { locale, isNegative }: { locale: string; isNegative: boolean }, +function aggregateAmount( + movement: Movement[], + isNegative: boolean, + locale: string, + decimals?: number, ) { - if (asset?.fungible) { - const numberOfDecimals = threshold.split('.')?.[1]?.length ?? 0; + const amountByAsset: Record = {}; - const amount = formatWithThreshold( - Number(asset?.amount), - Number(threshold), - locale, - { - minimumFractionDigits: 0, - maximumFractionDigits: numberOfDecimals, - }, - ); - - if (isNegative && !amount.startsWith('<')) { - return { - ...asset, - amount: `-${amount}`, - }; + for (const mv of movement) { + if (!mv?.asset.fungible) { + continue; + } + const assetId = mv.asset.type; + if (!amountByAsset[assetId]) { + amountByAsset[assetId] = mv; + continue; } - return { - ...asset, - amount, - }; + amountByAsset[assetId].asset.amount += Number(mv.asset.amount || 0); + } + + // Convert to a proper display array. + return Object.entries(amountByAsset).map(([_, mv]) => + parseAsset(mv, locale, isNegative, decimals), + ); +} + +function parseAsset( + movement: Movement, + locale: string, + isNegative: boolean, + decimals?: number, +) { + const displayAmount = formatWithThreshold( + Number(movement.asset.amount), + 0.00000001, + locale, + { + minimumFractionDigits: 0, + maximumFractionDigits: decimals || 8, + }, + ); + + let finalAmount = displayAmount; + if (isNegative && !displayAmount.startsWith('<')) { + finalAmount = `-${displayAmount}`; } - return null; + return { + amount: finalAmount, + unit: movement.asset.unit, + // It is not strictly correct to use the address here but we do not support sending multiple assets to multiple addresses + address: movement.address, + }; }