diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx index bbd576efda1c..04f3ab5c1628 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx @@ -454,6 +454,16 @@ const makeItem = ( } as unknown as ActivityListItem; }; +beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(selectCurrentCurrency).mockReturnValue('usd'); + jest.mocked(selectConversionRateByChainId).mockReturnValue(2500); + jest.mocked(selectUSDConversionRateByChainId).mockReturnValue(2500); + jest.mocked(selectContractExchangeRatesByChainId).mockReturnValue({ + [LINEA_MUSD_ADDRESS]: { price: 0.0004 }, + } as unknown as ReturnType); +}); + // --------------------------------------------------------------------------- // Row content tests — mirrors extension ActivityRow title/subtitle/amount split // --------------------------------------------------------------------------- diff --git a/app/components/Views/ActivityList/ActivityList.tsx b/app/components/Views/ActivityList/ActivityList.tsx index fe0a82ecd773..0e1e15d849d1 100644 --- a/app/components/Views/ActivityList/ActivityList.tsx +++ b/app/components/Views/ActivityList/ActivityList.tsx @@ -522,6 +522,10 @@ const ActivityList = forwardRef( const nonEvmItems = mapNonEvmTransactions( filteredNonEvmForMalicious, getBridgeHistoryItemByHash, + (transaction) => + selectedAccountGroupInternalAccounts.find( + (account) => account.id === transaction.account, + )?.address, ); // Drop confirmed copies whose local copy won above, so the winning local @@ -550,6 +554,7 @@ const ActivityList = forwardRef( relatedChainIdsByTransactionId, maliciousTokenKeys, isPerpsEnabled, + selectedAccountGroupInternalAccounts, ]); const data = useMemo(() => { diff --git a/app/components/Views/ActivityList/helpers/apply-bridge-quote.test.ts b/app/components/Views/ActivityList/helpers/apply-bridge-quote.test.ts new file mode 100644 index 000000000000..1279c8f8010a --- /dev/null +++ b/app/components/Views/ActivityList/helpers/apply-bridge-quote.test.ts @@ -0,0 +1,126 @@ +import { StatusTypes } from '@metamask/bridge-controller'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import { + TransactionStatus, + TransactionType, + type Transaction, +} from '@metamask/keyring-api'; +import { mapKeyringTransaction } from '@metamask/client-utils'; +import type { ActivityListItem } from '../../../../util/activity-adapters'; +import { applyBridgeQuote } from './apply-bridge-quote'; + +const solanaChainId = + 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ' as Transaction['chain']; + +describe('applyBridgeQuote', () => { + const makeKeyringTx = (overrides: Partial = {}): Transaction => + ({ + id: 'bridge-id', + chain: solanaChainId, + account: '00000000-0000-4000-8000-000000000000', + status: TransactionStatus.Confirmed, + timestamp: 1716367781, + type: TransactionType.Send, + from: [ + { + address: 'from-address', + asset: { + fungible: true, + type: `${solanaChainId}/slip44:501`, + unit: 'SOL', + amount: '1', + }, + }, + ], + to: [{ address: 'to-address', asset: null }], + fees: [], + events: [], + ...overrides, + }) as Transaction; + + const makeBridgeHistory = ({ + destChainId = 1, + bridgeStatus = StatusTypes.PENDING, + destChainAmount, + }: { + destChainId?: string | number; + bridgeStatus?: StatusTypes; + destChainAmount?: string; + } = {}): BridgeHistoryItem => + ({ + quote: { + srcChainId: solanaChainId, + destChainId, + srcAsset: { + assetId: `${solanaChainId}/slip44:501`, + decimals: 9, + symbol: 'SOL', + }, + srcTokenAmount: '1000000000', + destAsset: { + assetId: 'eip155:1/slip44:60', + decimals: 18, + symbol: 'ETH', + }, + destTokenAmount: '5000000000000000', + }, + status: { + status: bridgeStatus, + srcChain: { txHash: 'bridge-id' }, + ...(destChainAmount + ? { destChain: { txHash: '0xdest', amount: destChainAmount } } + : {}), + }, + }) as unknown as BridgeHistoryItem; + + it('maps a cross-chain bridge to a pending bridge item with quote-derived tokens', () => { + const item = applyBridgeQuote( + mapKeyringTransaction({ + transaction: makeKeyringTx(), + }) as ActivityListItem, + makeBridgeHistory(), + 'from-address', + ); + + expect(item).toMatchObject({ + type: 'bridge', + status: 'pending', + data: { + from: 'from-address', + sourceToken: { symbol: 'SOL', direction: 'out' }, + destinationToken: { symbol: 'ETH', direction: 'in' }, + }, + }); + }); + + it('marks the bridge successful once the destination leg lands', () => { + const item = applyBridgeQuote( + mapKeyringTransaction({ + transaction: makeKeyringTx({ type: TransactionType.Swap }), + }) as ActivityListItem, + makeBridgeHistory({ + bridgeStatus: StatusTypes.COMPLETE, + destChainAmount: '4990000000000000', + }), + ); + + expect(item).toMatchObject({ + type: 'bridge', + status: 'success', + data: { + destinationToken: { amount: '4990000000000000' }, + }, + }); + }); + + it('leaves same-chain swaps with bridge history on the regular keyring mapping', () => { + const item = applyBridgeQuote( + mapKeyringTransaction({ + transaction: makeKeyringTx({ type: TransactionType.Swap }), + }) as ActivityListItem, + makeBridgeHistory({ destChainId: solanaChainId }), + ); + + expect(item).toMatchObject({ type: 'swap', status: 'success' }); + }); +}); diff --git a/app/components/Views/ActivityList/helpers/apply-bridge-quote.ts b/app/components/Views/ActivityList/helpers/apply-bridge-quote.ts new file mode 100644 index 000000000000..33f983e0142b --- /dev/null +++ b/app/components/Views/ActivityList/helpers/apply-bridge-quote.ts @@ -0,0 +1,76 @@ +import { + isCrossChain, + StatusTypes as BridgeStatusTypes, +} from '@metamask/bridge-controller'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import type { ActivityListItem } from '../../../../util/activity-adapters'; + +function getBridgeActivityStatus(bridgeHistoryItem: BridgeHistoryItem) { + const { + quote, + status: { status }, + } = bridgeHistoryItem; + + if (status === BridgeStatusTypes.FAILED) { + return 'failed'; + } + + if (status === BridgeStatusTypes.COMPLETE) { + return 'success'; + } + + if ( + isCrossChain(quote.srcChainId, quote.destChainId) && + (status === BridgeStatusTypes.PENDING || + status === BridgeStatusTypes.SUBMITTED) + ) { + return 'pending'; + } + + return undefined; +} + +export function applyBridgeQuote( + activity: ActivityListItem, + bridgeHistory?: BridgeHistoryItem, + subjectAddress?: string, +) { + const quote = bridgeHistory?.quote; + if ( + !bridgeHistory || + !quote || + !isCrossChain(quote.srcChainId, quote.destChainId) + ) { + return activity; + } + + const fees = 'fees' in activity.data ? activity.data.fees : undefined; + const status = + activity.status === 'failed' + ? 'failed' + : getBridgeActivityStatus(bridgeHistory); + + return { + ...activity, + type: 'bridge', + ...(status ? { status } : {}), + data: { + from: subjectAddress, + sourceToken: { + amount: quote.srcTokenAmount, + assetId: quote.srcAsset.assetId, + decimals: quote.srcAsset.decimals, + direction: 'out', + symbol: quote.srcAsset.symbol, + }, + destinationToken: { + amount: bridgeHistory.status.destChain?.amount ?? quote.destTokenAmount, + assetId: quote.destAsset.assetId, + decimals: quote.destAsset.decimals, + direction: 'in', + symbol: quote.destAsset.symbol, + }, + ...(fees === undefined ? {} : { fees }), + }, + } as ActivityListItem; +} diff --git a/app/components/Views/ActivityList/helpers/transformations.ts b/app/components/Views/ActivityList/helpers/transformations.ts index f408b8f30db3..569dbeea9d22 100644 --- a/app/components/Views/ActivityList/helpers/transformations.ts +++ b/app/components/Views/ActivityList/helpers/transformations.ts @@ -3,20 +3,22 @@ * Produces ActivityListItem[] from API EVM and non-EVM transaction sources. * Local EVM transactions are handled separately by useLocalActivityItems. */ -import { mapApiTransaction } from '@metamask/client-utils'; +import { + mapApiTransaction, + mapKeyringTransaction, +} from '@metamask/client-utils'; import { type V1TransactionByHashResponse, type V4MultiAccountTransactionsResponse, } from '@metamask/core-backend'; +import { isCrossChain } from '@metamask/bridge-controller'; import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api'; import type { InfiniteData } from '@tanstack/react-query'; -import { - mapKeyringTransaction, - type ActivityListItem, -} from '../../../../util/activity-adapters'; +import { type ActivityListItem } from '../../../../util/activity-adapters'; import { mergeActivityItems } from '../../../../util/activity-adapters/adapters/dedup'; import { equalsIgnoreCase } from '../../../../util/string'; +import { applyBridgeQuote } from './apply-bridge-quote'; export type { ActivityListItem }; @@ -179,13 +181,29 @@ export function selectApiEvmTransactions({ export function mapNonEvmTransactions( transactions: NonEvmTransaction[], getBridgeHistoryItem?: (txId: string) => BridgeHistoryItem | undefined, + getSubjectAddress?: (transaction: NonEvmTransaction) => string | undefined, ): ActivityListItem[] { - return transactions.map((transaction) => - mapKeyringTransaction({ - transaction, - bridgeHistory: getBridgeHistoryItem?.(transaction.id), - }), - ); + return transactions.map((transaction) => { + const subjectAddress = getSubjectAddress?.(transaction); + const activity = { + ...mapKeyringTransaction({ + transaction: { + ...transaction, + fees: transaction.fees ?? [], + }, + subjectAddress, + }), + raw: { type: 'keyringTransaction' as const, data: transaction }, + } as ActivityListItem; + const bridgeHistoryItem = getBridgeHistoryItem?.(transaction.id); + const quote = bridgeHistoryItem?.quote; + + if (quote && isCrossChain(quote.srcChainId, quote.destChainId)) { + return applyBridgeQuote(activity, bridgeHistoryItem, subjectAddress); + } + + return activity; + }); } /** diff --git a/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.test.tsx b/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.test.tsx index 16d90f7197bc..2af2c4ee7183 100644 --- a/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.test.tsx +++ b/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.test.tsx @@ -7,10 +7,13 @@ import { TransactionStatus, TransactionType, } from '@metamask/keyring-api'; +import { mapKeyringTransaction } from '@metamask/client-utils'; import type { AppNavigationProp } from '../../../core/NavigationService/types'; +import type { ActivityListItem } from '../../../util/activity-adapters'; import MultichainAssetDetailsActivityListItem from './MultichainAssetDetailsActivityListItem'; import Routes from '../../../constants/navigation/Routes'; import { selectIsTransactionsRedesignEnabled } from '../../../selectors/featureFlagController/activityRedesign'; +import { selectNonEvmTransactionsForSelectedAccountGroup } from '../../../selectors/multichain/multichain'; import { handleUnifiedSwapsTxHistoryItemClick } from '../../UI/Bridge/utils/transaction-history'; jest.mock('react-redux', () => ({ @@ -104,23 +107,38 @@ const createTransaction = ( const mockUseSelector = jest.mocked(useSelector); +const mockSelectors = ({ + redesignEnabled = false, + keyringTransactions = [] as Transaction[], +} = {}) => { + mockUseSelector.mockImplementation((selector) => { + if (selector === selectIsTransactionsRedesignEnabled) { + return redesignEnabled; + } + if (selector === selectNonEvmTransactionsForSelectedAccountGroup) { + return { transactions: keyringTransactions }; + } + return undefined; + }); +}; + describe('MultichainAssetDetailsActivityListItem', () => { beforeEach(() => { jest.clearAllMocks(); - // Redesign disabled by default -> legacy bottom sheet. - mockUseSelector.mockImplementation(() => false); + mockSelectors(); }); it('routes to the ActivityDetails screen when the redesign is enabled', () => { - mockUseSelector.mockImplementation( - (selector) => selector === selectIsTransactionsRedesignEnabled, - ); const navigation = createNavigation(); const transaction = createTransaction(); + mockSelectors({ + redesignEnabled: true, + keyringTransactions: [transaction], + }); const { getByTestId } = render( { ); const navigation = createNavigation(); const transaction = createTransaction(); + mockSelectors({ keyringTransactions: [transaction] }); render( { it('opens multichain details when transaction has import insertion point', () => { const navigation = createNavigation(); const transaction = createTransaction({ insertImportTime: true }); + mockSelectors({ keyringTransactions: [transaction] }); const { getByTestId, queryByTestId } = render( { }) as never; it('routes a same-chain swap to the redesigned ActivityDetails screen', () => { - mockUseSelector.mockImplementation( - (selector) => selector === selectIsTransactionsRedesignEnabled, - ); const navigation = createNavigation(); + const transaction = createTransaction({ type: TransactionType.Swap }); + mockSelectors({ + redesignEnabled: true, + keyringTransactions: [transaction], + }); const { getByTestId } = render( { }); it('routes a cross-chain bridge to the redesigned ActivityDetails screen', () => { - mockUseSelector.mockImplementation( - (selector) => selector === selectIsTransactionsRedesignEnabled, - ); const navigation = createNavigation(); const transaction = createTransaction({ type: TransactionType.Swap }); + mockSelectors({ + redesignEnabled: true, + keyringTransactions: [transaction], + }); const { getByTestId } = render( { }); it('falls back to the bridge-status screen for a cross-chain bridge when the redesign is off', () => { - // mockUseSelector default in beforeEach returns false for every selector. const navigation = createNavigation(); const transaction = createTransaction({ type: TransactionType.Swap }); + mockSelectors({ keyringTransactions: [transaction] }); const { getByTestId } = render( { const { ActivityListItemRow } = jest.requireMock( '../../UI/ActivityListItemRow/ActivityListItemRow', ); + const transaction = createTransaction({ type: TransactionType.Swap }); + mockSelectors({ keyringTransactions: [transaction] }); const bridgeHistoryItem = createBridgeHistoryItem( SolScope.Mainnet, SolScope.Mainnet, @@ -300,7 +325,7 @@ describe('MultichainAssetDetailsActivityListItem', () => { render( - mapKeyringTransaction({ - transaction, - bridgeHistory: bridgeHistoryItem, - }), - [transaction, bridgeHistoryItem], + const keyringState = useSelector( + selectNonEvmTransactionsForSelectedAccountGroup, + ); + const transaction = keyringState?.transactions?.find( + (keyringTx) => keyringTx.id === item.hash && keyringTx.chain === chainId, ); + const displayData = useMultichainTransactionDisplay(transaction, chainId); const handlePress = useCallback(() => { - trackEvent( - createEventBuilder(ACTIVITY_DETAIL_EVENTS.OPENED) - .addProperties( - getMultichainTransactionDetailEventProperties({ - transaction, - chainId, - location, - bridgeHistoryItem, - }), - ) - .build(), - ); + if (transaction) { + trackEvent( + createEventBuilder(ACTIVITY_DETAIL_EVENTS.OPENED) + .addProperties( + getMultichainTransactionDetailEventProperties({ + transaction, + chainId, + location, + bridgeHistoryItem, + }), + ) + .build(), + ); + } if (isTransactionsRedesignEnabled) { - const detailsRoute = getActivityDetailsRoute(activityItem); + const detailsRoute = getActivityDetailsRoute(item); if (detailsRoute) { navigation.navigate(Routes.ACTIVITY_DETAILS, detailsRoute); return; @@ -85,12 +85,16 @@ export const MultichainAssetDetailsActivityListItem = ({ return; } + if (!transaction) { + return; + } + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { screen: Routes.SHEET.MULTICHAIN_TRANSACTION_DETAILS, params: { displayData, transaction }, }); }, [ - activityItem, + item, bridgeHistoryItem, chainId, createEventBuilder, @@ -106,7 +110,7 @@ export const MultichainAssetDetailsActivityListItem = ({ diff --git a/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.utils.test.ts b/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.utils.test.ts index 31982b912c88..d50aa00dcac2 100644 --- a/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.utils.test.ts +++ b/app/components/Views/MultichainTransactionsView/MultichainAssetDetailsActivityListItem.utils.test.ts @@ -6,7 +6,6 @@ import { } from '@metamask/keyring-api'; import { TransactionDetailLocation } from '../../../core/Analytics/events/transactions'; import { MonetizedPrimitive } from '../../../core/Analytics/MetaMetrics.types'; -import { mapKeyringTransaction } from '../../../util/activity-adapters'; import { getMultichainTransactionDetailEventProperties } from './MultichainAssetDetailsActivityListItem.utils'; const createTransaction = (overrides: Partial = {}): Transaction => @@ -35,121 +34,75 @@ const createTransaction = (overrides: Partial = {}): Transaction => }) as Transaction; describe('MultichainAssetDetailsActivityListItem utils', () => { - it('maps a keyring transaction to an activity item on its own chain', () => { + it('builds transaction detail event properties with asset details location', () => { const transaction = createTransaction(); - const item = mapKeyringTransaction({ transaction }); - - expect(item).toEqual( - expect.objectContaining({ + expect( + getMultichainTransactionDetailEventProperties({ + transaction, chainId: SolScope.Mainnet, - hash: 'tx-1', - raw: expect.objectContaining({ - type: 'keyringTransaction', - }), - type: 'send', + location: TransactionDetailLocation.AssetDetails, + }), + ).toEqual( + expect.objectContaining({ + location: TransactionDetailLocation.AssetDetails, + chain_id_source: SolScope.Mainnet, + chain_id_destination: SolScope.Mainnet, + transaction_type: 'send', }), ); }); - it('builds transaction detail event properties with asset details location', () => { + it('reports the quote chains as CAIP ids and the swaps primitive when a bridge history entry exists', () => { const transaction = createTransaction(); expect( getMultichainTransactionDetailEventProperties({ transaction, chainId: SolScope.Mainnet, - location: TransactionDetailLocation.AssetDetails, + bridgeHistoryItem: { + status: { status: 'COMPLETE' }, + quote: { + srcChainId: SolScope.Mainnet, + destChainId: 'eip155:1', + srcAsset: { chainId: SolScope.Mainnet }, + destAsset: { chainId: 'eip155:1' }, + }, + } as never, }), - ).toStrictEqual({ - transaction_type: TransactionType.Send, - transaction_status: TransactionStatus.Confirmed, - location: TransactionDetailLocation.AssetDetails, - chain_id_source: SolScope.Mainnet, - chain_id_destination: SolScope.Mainnet, - }); - }); - - it('reports the quote chains as CAIP ids and the swaps primitive when a bridge history entry exists', () => { - // Real quotes carry chain ids as bridge-API numbers, not CAIP strings. - const SOLANA_NUMERIC_CHAIN_ID = 1151111081099710; - const transaction = createTransaction({ type: TransactionType.Swap }); - - const properties = getMultichainTransactionDetailEventProperties({ - transaction, - chainId: SolScope.Mainnet, - location: TransactionDetailLocation.AssetDetails, - bridgeHistoryItem: { - quote: { - srcChainId: SOLANA_NUMERIC_CHAIN_ID, - destChainId: 1, - }, - } as never, - }); - - expect(properties).toStrictEqual({ - transaction_type: 'bridge', - transaction_status: TransactionStatus.Confirmed, - location: TransactionDetailLocation.AssetDetails, - chain_id_source: SolScope.Mainnet, - chain_id_destination: 'eip155:1', - monetized_primitive: MonetizedPrimitive.Swaps, - }); - }); - - it('classifies a same-chain bridge history entry as a swap', () => { - const SOLANA_NUMERIC_CHAIN_ID = 1151111081099710; - const transaction = createTransaction({ type: TransactionType.Swap }); - - const properties = getMultichainTransactionDetailEventProperties({ - transaction, - chainId: SolScope.Mainnet, - bridgeHistoryItem: { - quote: { - srcChainId: SOLANA_NUMERIC_CHAIN_ID, - destChainId: SOLANA_NUMERIC_CHAIN_ID, - }, - } as never, - }); - - expect(properties).toEqual( + ).toEqual( expect.objectContaining({ - transaction_type: 'swap', + transaction_type: 'bridge', + monetized_primitive: MonetizedPrimitive.Swaps, chain_id_source: SolScope.Mainnet, - chain_id_destination: SolScope.Mainnet, + chain_id_destination: 'eip155:1', }), ); }); - it('maps a keyring swap that carries cross-chain bridge history to a bridge item', () => { - const transaction = createTransaction({ type: TransactionType.Swap }); + it('classifies a same-chain bridge history entry as a swap', () => { + const transaction = createTransaction(); - const item = mapKeyringTransaction({ - transaction, - bridgeHistory: { - status: { status: 'COMPLETE' }, - quote: { - srcChainId: SolScope.Mainnet, - destChainId: 'eip155:1', - srcTokenAmount: '1000000000', - destTokenAmount: '1000000', - srcAsset: { - chainId: SolScope.Mainnet, - assetId: `${SolScope.Mainnet}/slip44:501`, - decimals: 9, - symbol: 'SOL', - }, - destAsset: { - chainId: 'eip155:1', - assetId: 'eip155:1/slip44:60', - decimals: 6, - symbol: 'USDC', + expect( + getMultichainTransactionDetailEventProperties({ + transaction, + chainId: SolScope.Mainnet, + bridgeHistoryItem: { + status: { status: 'COMPLETE' }, + quote: { + srcChainId: SolScope.Mainnet, + destChainId: SolScope.Mainnet, + srcAsset: { chainId: SolScope.Mainnet }, + destAsset: { chainId: SolScope.Mainnet }, }, - }, - } as never, - }); - - expect(item.type).toBe('bridge'); + } as never, + }), + ).toEqual( + expect.objectContaining({ + transaction_type: 'swap', + monetized_primitive: MonetizedPrimitive.Swaps, + }), + ); }); it('defaults transaction detail event location to home', () => { diff --git a/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.test.tsx b/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.test.tsx index 2b4a17c509c8..da5cdf3fe57f 100644 --- a/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.test.tsx +++ b/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.test.tsx @@ -8,9 +8,7 @@ import { TransactionStatus, TransactionType, } from '@metamask/keyring-api'; -import MultichainTransactionsView, { - getMultichainTransactionItemType, -} from './MultichainTransactionsView'; +import MultichainTransactionsView from './MultichainTransactionsView'; import { selectNonEvmTransactions } from '../../../selectors/multichain'; import { selectSelectedInternalAccountFormattedAddress } from '../../../selectors/accountsController'; import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; @@ -25,7 +23,6 @@ import { TransactionDetailLocation } from '../../../core/Analytics/events/transa import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController'; import { handleUnifiedSwapsTxHistoryItemClick } from '../../UI/Bridge/utils/transaction-history'; import Routes from '../../../constants/navigation/Routes'; -jest.useFakeTimers(); jest.mock('../../../util/analytics/externalLinkTracking', () => ({ ...jest.requireActual('../../../util/analytics/externalLinkTracking'), @@ -141,20 +138,6 @@ describe('MultichainTransactionsView', () => { }, ]; - it('uses distinct recycle pools for standard and bridge transactions', () => { - expect( - getMultichainTransactionItemType(mockTransactions[0], false, {}), - ).toBe('transaction'); - expect( - getMultichainTransactionItemType(mockTransactions[1], false, {}), - ).toBe('transaction'); - expect( - getMultichainTransactionItemType(mockTransactions[0], false, { - [mockTransactions[0].id]: {}, - }), - ).toBe('bridge-transaction'); - }); - const customRender = (ui: React.ReactElement) => { const utils = render(ui); @@ -171,7 +154,6 @@ describe('MultichainTransactionsView', () => { beforeEach(() => { jest.clearAllMocks(); - jest.clearAllTimers(); const { default: MockButton } = jest.requireMock( '../../../component-library/components/Buttons/Button', diff --git a/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.tsx b/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.tsx index 0165745d96ef..fd29591e2b1a 100644 --- a/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.tsx +++ b/app/components/Views/MultichainTransactionsView/MultichainTransactionsView.tsx @@ -28,6 +28,7 @@ import PriceChartContext, { PriceChartProvider, } from '../../UI/AssetOverview/PriceChart/PriceChart.context'; import MultichainBridgeTransactionListItem from '../../../components/UI/MultichainBridgeTransactionListItem'; +import { isCrossChain } from '@metamask/bridge-controller'; import { KnownCaipNamespace, parseCaipChainId } from '@metamask/utils'; import { SupportedCaipChainId } from '@metamask/multichain-network-controller'; import { TabEmptyState } from '../../../component-library/components-temp/TabEmptyState'; @@ -38,10 +39,10 @@ import { selectIsActivityRedesignEnabled, selectIsTransactionsRedesignEnabled, } from '../../../selectors/featureFlagController/activityRedesign'; +import { mapKeyringTransaction } from '@metamask/client-utils'; import { getGroupedActivityListItemKey, groupActivityListItems, - mapKeyringTransaction, type ActivityListItem, type GroupedActivityListItem, } from '../../../util/activity-adapters'; @@ -51,8 +52,10 @@ import { Box } from '@metamask/design-system-react-native'; import { selectBridgeHistoryForAccount } from '../../../selectors/bridgeStatusController'; import { findBridgeHistoryItem } from '../../../util/bridge/findBridgeHistoryItem'; import { handleUnifiedSwapsTxHistoryItemClick } from '../../UI/Bridge/utils/transaction-history'; -// eslint-disable-next-line import-x/no-restricted-paths -- TODO(ADR-0020): shared activity-details routing; route-isolation backlog +/* eslint-disable import-x/no-restricted-paths -- TODO(ADR-0020): shared activity-details routing; route-isolation backlog */ import { getActivityDetailsRoute } from '../ActivityList/getActivityDetailsRoute'; +import { applyBridgeQuote } from '../ActivityList/helpers/apply-bridge-quote'; +/* eslint-enable import-x/no-restricted-paths */ import Routes from '../../../constants/navigation/Routes'; import { mapTransactionToActivityItem } from '../../UI/Transactions/AssetDetailsActivityListItem.utils'; import MultichainAssetDetailsActivityListItem from './MultichainAssetDetailsActivityListItem'; @@ -104,7 +107,7 @@ interface MultichainTransactionsViewProps { bridgeArrivalTransactions?: TransactionMeta[]; } -export const getMultichainTransactionItemType = ( +const getMultichainTransactionItemType = ( item: Pick | GroupedActivityListItem, shouldUseActivityRedesign: boolean, bridgeHistoryItemsBySrcTxHash: Readonly>, @@ -114,12 +117,9 @@ export const getMultichainTransactionItemType = ( return item.type; } - const transaction = - 'item' in item && item.item.raw?.type === 'keyringTransaction' - ? item.item.raw.data - : undefined; + const hash = 'item' in item ? item.item.hash : undefined; - return transaction && bridgeHistoryItemsBySrcTxHash[transaction.id] + return hash && bridgeHistoryItemsBySrcTxHash[hash] ? 'bridge-activity' : 'activity-item'; } @@ -231,20 +231,33 @@ const MultichainTransactionsView = ({ (transaction) => !arrivalDestTxHashes.has(transaction.id?.toLowerCase()), ) - .map((transaction) => - mapKeyringTransaction({ + .map((transaction) => { + const activity = mapKeyringTransaction({ transaction: { ...transaction, chain: transaction.chain ?? chainId, + fees: transaction.fees ?? [], }, - bridgeHistory: - bridgeHistoryItemsBySrcTxHash[transaction.id] ?? - bridgeHistoryItemsByDestTxHash[transaction.id], - }), - ), + subjectAddress: address, + }) as ActivityListItem; + const bridgeHistoryItem = + bridgeHistoryItemsBySrcTxHash[transaction.id] ?? + bridgeHistoryItemsByDestTxHash[transaction.id]; + const quote = bridgeHistoryItem?.quote; + + if ( + quote && + isCrossChain(quote.srcChainId, quote.destChainId) + ) { + return applyBridgeQuote(activity, bridgeHistoryItem, address); + } + + return activity; + }), ]) : visibleMultichainTransactions, [ + address, arrivalDestTxHashes, bridgeArrivalItems, bridgeHistoryItemsByDestTxHash, @@ -340,21 +353,6 @@ const MultichainTransactionsView = ({ const srcTxHash = item.id; const bridgeHistoryItem = bridgeHistoryItemsBySrcTxHash[srcTxHash]; - if (shouldUseActivityRedesign) { - return ( - - ); - } - if (bridgeHistoryItem) { return ( + ); }; const renderListItem = ({ diff --git a/app/components/hooks/useMultichainTransactionDisplay/useMultichainTransactionDisplay.ts b/app/components/hooks/useMultichainTransactionDisplay/useMultichainTransactionDisplay.ts index 881399343cdb..2e2a49b49fc7 100644 --- a/app/components/hooks/useMultichainTransactionDisplay/useMultichainTransactionDisplay.ts +++ b/app/components/hooks/useMultichainTransactionDisplay/useMultichainTransactionDisplay.ts @@ -50,10 +50,14 @@ export interface MultichainTransactionDisplayData { const APPROVE_AMOUNT_UNLIMITED_THRESHOLD = 1e15; export function useMultichainTransactionDisplay( - transaction: Transaction, + transaction: Transaction | undefined, chainId: CaipChainId, ): MultichainTransactionDisplayData { const locale = I18n.locale; + if (!transaction) { + return { isRedeposit: false }; + } + const decimalPlaces = MULTICHAIN_NETWORK_DECIMAL_PLACES[chainId]; const isRedeposit = transaction.to.length === 0 && transaction.type === TransactionType.Send; diff --git a/app/util/activity-adapters/adapters/keyring-transaction.test.ts b/app/util/activity-adapters/adapters/keyring-transaction.test.ts deleted file mode 100644 index 04a877799d9b..000000000000 --- a/app/util/activity-adapters/adapters/keyring-transaction.test.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { StatusTypes } from '@metamask/bridge-controller'; -import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; -import { - TransactionStatus, - TransactionType, - type Transaction, -} from '@metamask/keyring-api'; -import { mapKeyringTransaction } from './keyring-transaction'; - -const SOLANA_CHAIN_ID = - 'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ' as Transaction['chain']; -const BITCOIN_CHAIN_ID = - 'bip122:000000000019d6689c085ae165831e93' as Transaction['chain']; - -describe('mapKeyringTransaction', () => { - it('maps keyring send transactions with token amount data', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'send-id', - chain: SOLANA_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.Send, - from: [ - { - address: 'from-address', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/token:usdc`, - unit: 'USDC', - amount: '2.5', - }, - }, - ], - to: [{ address: 'to-address', asset: null }], - fees: [], - events: [], - } as Transaction, - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'send', - chainId: SOLANA_CHAIN_ID, - status: 'success', - timestamp: 1716367781000, - hash: 'send-id', - data: { - from: 'from-address', - to: 'to-address', - token: { - amount: '2.5', - assetId: `${SOLANA_CHAIN_ID}/token:usdc`, - direction: 'out', - symbol: 'USDC', - }, - }, - }), - ); - }); - - it('maps keyring swap transactions with source and destination token amounts', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'swap-id', - chain: SOLANA_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Submitted, - timestamp: 1716367781, - type: TransactionType.Swap, - from: [ - { - address: 'from-address', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/slip44:501`, - unit: 'SOL', - amount: '1', - }, - }, - ], - to: [ - { - address: 'to-address', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/token:usdc`, - unit: 'USDC', - amount: '100', - }, - }, - ], - fees: [], - events: [], - } as Transaction, - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'swap', - chainId: SOLANA_CHAIN_ID, - status: 'pending', - timestamp: 1716367781000, - hash: 'swap-id', - data: { - sourceToken: { - amount: '1', - assetId: `${SOLANA_CHAIN_ID}/slip44:501`, - direction: 'out', - symbol: 'SOL', - }, - destinationToken: { - amount: '100', - assetId: `${SOLANA_CHAIN_ID}/token:usdc`, - direction: 'in', - symbol: 'USDC', - }, - }, - }), - ); - }); - - it('maps bitcoin send token from to-movement when from is empty', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'btc-send-output-id', - chain: BITCOIN_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.Send, - from: [{ address: 'bc1from', asset: null }], - to: [ - { - address: 'bc1to', - asset: { - fungible: true, - type: `${BITCOIN_CHAIN_ID}/slip44:0`, - unit: 'BTC', - amount: '0.1', - }, - }, - ], - fees: [], - events: [], - } as Transaction, - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'send', - chainId: BITCOIN_CHAIN_ID, - status: 'success', - timestamp: 1716367781000, - hash: 'btc-send-output-id', - data: { - from: 'bc1from', - to: 'bc1to', - token: { - amount: '0.1', - assetId: `${BITCOIN_CHAIN_ID}/slip44:0`, - direction: 'out', - symbol: 'BTC', - }, - }, - }), - ); - }); - - it('maps a send transaction missing its chain instead of throwing', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'no-chain-id', - chain: undefined, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.Send, - from: [{ address: 'from-address', asset: null }], - to: [{ address: 'to-address', asset: null }], - fees: [], - events: [], - } as unknown as Transaction, - }); - - expect(item).toEqual( - expect.objectContaining({ - type: 'send', - chainId: undefined, - hash: 'no-chain-id', - }), - ); - }); - - it('maps trustline approve transactions to assetActivation activity items', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'trustline-add-id', - chain: 'stellar:pubnet', - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.TokenApprove, - from: [ - { - address: 'GABC123', - asset: { - fungible: true, - type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - unit: 'USDC', - amount: '0', - }, - }, - ], - to: [{ address: 'GABC123', asset: null }], - fees: [], - events: [], - details: { - typeLabel: 'trustline-approve', - }, - } as Transaction, - }); - - expect(item).toMatchObject({ - type: 'assetActivation', - data: { - from: 'GABC123', - token: { - assetId: - 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - direction: 'out', - symbol: 'USDC', - }, - }, - }); - if (item?.type === 'assetActivation') { - expect(item.data.token?.amount).toBeUndefined(); - } - }); - - it('maps trustline disapprove transactions to assetDeactivation activity items', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'trustline-remove-id', - chain: 'stellar:pubnet', - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.TokenDisapprove, - from: [ - { - address: 'GABC123', - asset: { - fungible: true, - type: 'stellar:pubnet/asset:USDC-GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', - unit: 'USDC', - amount: '0', - }, - }, - ], - to: [{ address: 'GABC123', asset: null }], - fees: [], - events: [], - details: { - typeLabel: 'trustline-disapprove', - }, - } as Transaction, - }); - - expect(item).toMatchObject({ - type: 'assetDeactivation', - data: { - from: 'GABC123', - token: { - symbol: 'USDC', - direction: 'out', - }, - }, - }); - }); - - it('maps token approvals as spending-cap activity with unlimited metadata', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'approve-id', - chain: SOLANA_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.TokenApprove, - from: [ - { - address: 'owner-address', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/token:usdt`, - unit: 'USDT', - amount: '115792089237316195423570985.639935', - }, - }, - ], - to: [ - { - address: 'spender-address', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/token:usdt`, - unit: 'USDT', - amount: '115792089237316195423570985.639935', - }, - }, - ], - fees: [], - events: [], - } as Transaction, - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'approveSpendingCap', - chainId: SOLANA_CHAIN_ID, - status: 'success', - timestamp: 1716367781000, - hash: 'approve-id', - data: { - token: { - amount: '115792089237316195423570985.639935', - assetId: `${SOLANA_CHAIN_ID}/token:usdt`, - direction: 'out', - isUnlimitedApproval: true, - symbol: 'USDT', - }, - }, - }), - ); - }); - - it('maps keyring contract interaction fees', () => { - const item = mapKeyringTransaction({ - transaction: { - id: 'contract-id', - chain: SOLANA_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.Unknown, - from: [{ address: 'from-address', asset: null }], - to: [{ address: 'to-address', asset: null }], - fees: [ - { - type: 'base', - asset: { - fungible: true, - type: `${SOLANA_CHAIN_ID}/slip44:501`, - unit: 'SOL', - amount: '0.00001', - }, - }, - ], - events: [], - } as Transaction, - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'contractInteraction', - data: expect.objectContaining({ - fees: [ - { - type: 'base', - amount: '0.00001', - assetId: `${SOLANA_CHAIN_ID}/slip44:501`, - symbol: 'SOL', - }, - ], - }), - }), - ); - }); - - describe('bridge-history enrichment', () => { - const EVM_CHAIN_ID = '0x1'; - - const makeKeyringTx = (overrides: Partial = {}): Transaction => - ({ - id: 'bridge-id', - chain: SOLANA_CHAIN_ID, - account: '00000000-0000-4000-8000-000000000000', - status: TransactionStatus.Confirmed, - timestamp: 1716367781, - type: TransactionType.Send, - from: [{ address: 'from-address', asset: null }], - to: [], - fees: [], - events: [], - ...overrides, - }) as Transaction; - - const makeBridgeHistory = ({ - destChainId = EVM_CHAIN_ID, - bridgeStatus = StatusTypes.PENDING, - destChainAmount, - }: { - destChainId?: string | number; - bridgeStatus?: StatusTypes; - destChainAmount?: string; - } = {}): BridgeHistoryItem => - ({ - quote: { - srcChainId: SOLANA_CHAIN_ID, - destChainId, - srcAsset: { - assetId: `${SOLANA_CHAIN_ID}/slip44:501`, - decimals: 9, - symbol: 'SOL', - }, - srcTokenAmount: '1000000000', - destAsset: { - assetId: 'eip155:1/slip44:60', - decimals: 18, - symbol: 'ETH', - }, - destTokenAmount: '5000000000000000', - }, - status: { - status: bridgeStatus, - srcChain: { txHash: 'bridge-id' }, - ...(destChainAmount - ? { destChain: { txHash: '0xdest', amount: destChainAmount } } - : {}), - }, - }) as unknown as BridgeHistoryItem; - - it('maps a cross-chain bridge to a pending bridge item with quote-derived tokens', () => { - const item = mapKeyringTransaction({ - transaction: makeKeyringTx(), - bridgeHistory: makeBridgeHistory(), - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'bridge', - chainId: SOLANA_CHAIN_ID, - // The confirmed source leg must not read as a completed bridge while - // the destination leg is still in flight. - status: 'pending', - hash: 'bridge-id', - data: { - sourceToken: { - amount: '1000000000', - assetId: `${SOLANA_CHAIN_ID}/slip44:501`, - decimals: 9, - direction: 'out', - symbol: 'SOL', - }, - destinationToken: { - amount: '5000000000000000', - assetId: 'eip155:1/slip44:60', - decimals: 18, - direction: 'in', - symbol: 'ETH', - }, - }, - }), - ); - }); - - it('marks the bridge successful and prefers the received amount once the destination leg lands', () => { - const item = mapKeyringTransaction({ - // Snaps report bridge sources through several keyring types; swap-typed - // txs must enrich the same way send-typed ones do. - transaction: makeKeyringTx({ type: TransactionType.Swap }), - bridgeHistory: makeBridgeHistory({ - bridgeStatus: StatusTypes.COMPLETE, - destChainAmount: '4990000000000000', - }), - }); - - expect(item).toStrictEqual( - expect.objectContaining({ - type: 'bridge', - status: 'success', - data: expect.objectContaining({ - destinationToken: expect.objectContaining({ - amount: '4990000000000000', - }), - }), - }), - ); - }); - - it('maps a failed bridge status to a failed item', () => { - const item = mapKeyringTransaction({ - transaction: makeKeyringTx(), - bridgeHistory: makeBridgeHistory({ bridgeStatus: StatusTypes.FAILED }), - }); - - expect(item).toStrictEqual( - expect.objectContaining({ type: 'bridge', status: 'failed' }), - ); - }); - - it('reads a failed source tx as failed before the bridge status catches up', () => { - const item = mapKeyringTransaction({ - transaction: makeKeyringTx({ status: TransactionStatus.Failed }), - bridgeHistory: makeBridgeHistory({ bridgeStatus: StatusTypes.PENDING }), - }); - - expect(item).toStrictEqual( - expect.objectContaining({ type: 'bridge', status: 'failed' }), - ); - }); - - it('leaves same-chain swaps with bridge history on the regular keyring mapping', () => { - const item = mapKeyringTransaction({ - transaction: makeKeyringTx({ type: TransactionType.Swap }), - bridgeHistory: makeBridgeHistory({ destChainId: SOLANA_CHAIN_ID }), - }); - - expect(item).toStrictEqual( - expect.objectContaining({ type: 'swap', status: 'success' }), - ); - }); - }); -}); diff --git a/app/util/activity-adapters/adapters/keyring-transaction.ts b/app/util/activity-adapters/adapters/keyring-transaction.ts deleted file mode 100644 index eaa7e7c3593f..000000000000 --- a/app/util/activity-adapters/adapters/keyring-transaction.ts +++ /dev/null @@ -1,376 +0,0 @@ -/** - * Vendored from metamask-extension shared/lib/activity/adapters/keyring-transaction.ts - * Branch: origin/n3ps/activity-v3-prototype - * TODO: Replace with shared @metamask/activity-adapters package when published. - */ -import { - isCrossChain, - StatusTypes as BridgeStatusTypes, -} from '@metamask/bridge-controller'; -import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; -import { - type Transaction, - TransactionStatus as KeyringTransactionStatus, - TransactionType as KeyringTransactionType, -} from '@metamask/keyring-api'; -import type { - ActivityFee, - ActivityListItem, - Status, - TokenAmount, -} from '../types'; -import { hasTrustlineTypeLabel } from '../trustline'; - -type Movement = Transaction['from'][number]; -type Fee = Transaction['fees'][number]; -type FungibleAsset = Extract< - NonNullable, - { fungible: true } ->; - -// Mirrors EVM TOKEN_VALUE_UNLIMITED_THRESHOLD: amounts above 10^15 are treated as unlimited. -const APPROVE_AMOUNT_UNLIMITED_THRESHOLD = 1e15; - -function mapStatus(status: Transaction['status']): Status { - switch (status) { - case KeyringTransactionStatus.Confirmed: - return 'success'; - case KeyringTransactionStatus.Failed: - return 'failed'; - case KeyringTransactionStatus.Submitted: - case KeyringTransactionStatus.Unconfirmed: - default: - return 'pending'; - } -} - -function mapBridgeStatus(bridgeStatus: BridgeStatusTypes): Status { - switch (bridgeStatus) { - case BridgeStatusTypes.FAILED: - return 'failed'; - case BridgeStatusTypes.COMPLETE: - return 'success'; - case BridgeStatusTypes.PENDING: - case BridgeStatusTypes.SUBMITTED: - default: - return 'pending'; - } -} - -function mapTimestamp(timestamp: Transaction['timestamp']) { - return timestamp ? timestamp * 1000 : 0; -} - -function getAddress(movements: Movement[]) { - return movements[0]?.address ?? ''; -} - -function hasFungibleAsset( - movement: Movement, -): movement is Movement & { asset: FungibleAsset } { - return movement.asset?.fungible === true; -} - -function getToken( - movements: Movement[], - direction: TokenAmount['direction'], -): TokenAmount | undefined { - const movement = movements.find(hasFungibleAsset); - - if (!movement) { - return undefined; - } - - return { - amount: movement.asset.amount, - symbol: movement.asset.unit, - assetId: movement.asset.type, - direction, - }; -} - -function getFee(fee: Fee): ActivityFee | undefined { - const { asset } = fee; - - if (asset.fungible !== true) { - return undefined; - } - - return { - type: fee.type, - amount: asset.amount, - symbol: asset.unit, - assetId: asset.type, - }; -} - -function getFees(transaction: Transaction): ActivityFee[] | undefined { - const fees = (transaction.fees ?? []).flatMap((fee) => { - const mappedFee = getFee(fee); - - return mappedFee ? [mappedFee] : []; - }); - - return fees.length ? fees : undefined; -} - -function isUnlimitedApprovalMovement(movement: Movement) { - return ( - hasFungibleAsset(movement) && - Number.parseFloat(movement.asset.amount) > - APPROVE_AMOUNT_UNLIMITED_THRESHOLD - ); -} - -function getApprovalToken(transaction: Transaction): TokenAmount | undefined { - const movement = - transaction.to.find(hasFungibleAsset) ?? - transaction.from.find(hasFungibleAsset); - - if (!movement) { - return undefined; - } - - return { - amount: movement.asset.amount, - assetId: movement.asset.type, - direction: 'out', - isUnlimitedApproval: - transaction.from.some(isUnlimitedApprovalMovement) || - transaction.to.some(isUnlimitedApprovalMovement), - symbol: movement.asset.unit, - }; -} - -function aggregateMovementAmount(movements: Movement[]) { - const amountByAssetType: Record< - string, - { - amount: number; - unit: string; - } - > = {}; - - for (const movement of movements) { - if (!hasFungibleAsset(movement)) { - continue; - } - - const { type: assetType, unit } = movement.asset; - const parsedAmount = Number.parseFloat(movement.asset.amount); - const normalizedAmount = Number.isFinite(parsedAmount) ? parsedAmount : 0; - - if (!amountByAssetType[assetType]) { - amountByAssetType[assetType] = { - amount: normalizedAmount, - unit, - }; - continue; - } - - amountByAssetType[assetType].amount += normalizedAmount; - } - - const entries = Object.entries(amountByAssetType); - if (entries.length !== 1) { - return undefined; - } - - const [assetType, aggregate] = entries[0]; - return { - assetType, - amount: String(aggregate.amount), - unit: aggregate.unit, - }; -} - -// Converts keyring API transactions into the shared activity item shape -export function mapKeyringTransaction({ - transaction, - bridgeHistory, -}: { - transaction: Transaction; - bridgeHistory?: BridgeHistoryItem; -}): ActivityListItem { - const status = mapStatus(transaction.status); - const timestamp = mapTimestamp(transaction.timestamp); - const chainId = transaction.chain; - const from = getAddress(transaction.from); - const to = getAddress(transaction.to); - const fees = getFees(transaction); - - if ( - bridgeHistory && - isCrossChain( - bridgeHistory.quote.srcChainId, - bridgeHistory.quote.destChainId, - ) - ) { - const { quote } = bridgeHistory; - - return { - type: 'bridge', - chainId, - status: - status === 'failed' - ? 'failed' - : mapBridgeStatus(bridgeHistory.status.status), - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - sourceToken: { - amount: quote.srcTokenAmount, - assetId: quote.srcAsset.assetId, - decimals: quote.srcAsset.decimals, - direction: 'out', - symbol: quote.srcAsset.symbol, - }, - destinationToken: { - amount: - bridgeHistory.status.destChain?.amount ?? quote.destTokenAmount, - assetId: quote.destAsset.assetId, - decimals: quote.destAsset.decimals, - direction: 'in', - symbol: quote.destAsset.symbol, - }, - ...(fees ? { fees } : {}), - }, - }; - } - - if (transaction.type === KeyringTransactionType.Send) { - const fromToken = getToken(transaction.from, 'out'); - let token = fromToken; - - // Bitcoin transaction.from can be empty, resulting in no token avatar or send amount displayed. - // Workaround: use the same aggregated to-asset fallback strategy as tx details modal. - if (!fromToken && chainId?.startsWith('bip122:')) { - const aggregatedToAsset = aggregateMovementAmount(transaction.to); - if (aggregatedToAsset) { - token = { - direction: 'out', - assetId: aggregatedToAsset.assetType, - symbol: aggregatedToAsset.unit, - amount: aggregatedToAsset.amount, - }; - } - } - - return { - type: 'send', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - from, - to, - token, - ...(fees ? { fees } : {}), - }, - }; - } - - if (transaction.type === KeyringTransactionType.Receive) { - return { - type: 'receive', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - from, - to, - token: getToken(transaction.to, 'in'), - ...(fees ? { fees } : {}), - }, - }; - } - - if (transaction.type === KeyringTransactionType.Swap) { - return { - type: 'swap', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - destinationToken: getToken(transaction.to, 'in'), - sourceToken: getToken(transaction.from, 'out'), - ...(fees ? { fees } : {}), - }, - }; - } - - if (transaction.type === KeyringTransactionType.TokenApprove) { - const rawToken = getToken(transaction.from, 'out'); - - if (hasTrustlineTypeLabel(transaction.details)) { - return { - type: 'assetActivation', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - from, - token: rawToken ? { ...rawToken, amount: undefined } : undefined, - ...(fees ? { fees } : {}), - }, - }; - } - - return { - type: 'approveSpendingCap', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - token: getApprovalToken(transaction), - ...(fees ? { fees } : {}), - }, - }; - } - - if (transaction.type === KeyringTransactionType.TokenDisapprove) { - const rawToken = getToken(transaction.from, 'out'); - - if (hasTrustlineTypeLabel(transaction.details)) { - return { - type: 'assetDeactivation', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - from, - token: rawToken ? { ...rawToken, amount: undefined } : undefined, - ...(fees ? { fees } : {}), - }, - }; - } - } - - return { - type: 'contractInteraction', - chainId, - status, - timestamp, - hash: transaction.id, - raw: { type: 'keyringTransaction', data: transaction }, - data: { - from, - to, - transactionType: transaction.type, - ...(fees ? { fees } : {}), - }, - }; -} diff --git a/app/util/activity-adapters/index.ts b/app/util/activity-adapters/index.ts index 9fa04ceb0c5f..c60eee5e4f08 100644 --- a/app/util/activity-adapters/index.ts +++ b/app/util/activity-adapters/index.ts @@ -16,7 +16,6 @@ export { isNftTransferType, isUnlimitedApprovalAmount, } from './adapters/helpers'; -export { mapKeyringTransaction } from './adapters/keyring-transaction'; export { enrichLocalActivity, prepareLocalTransactionGroup,