diff --git a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx index 1f5959a7484..692d35d14f2 100644 --- a/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx +++ b/app/components/UI/ActivityListItemRow/ActivityListItemRow.test.tsx @@ -1117,6 +1117,64 @@ describe('ActivityListItemRow — row content', () => { jest.mocked(useTokensData).mockReturnValue({}); }); + it('resolves a lending-deposit token symbol/decimals from the tokens API and scales the amount', () => { + const assetId = + 'eip155:42161/erc20:0x0000000000000000000000000000000000000002'; + jest.mocked(useTokensData).mockReturnValue({ + [assetId]: { + assetId, + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + iconUrl: '', + }, + }); + + // The adapter carries only the atomic amount + asset id (the tx targets the + // pool, so symbol/decimals aren't in local metadata). Without decimals the + // amount would render unscaled (10,000 instead of 0.01). + const item = makeItem({ + type: 'lendingDeposit', + status: 'success', + chainId: 'eip155:42161', + sourceToken: { amount: '10000', direction: 'out', assetId }, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('activity-primary-amount-0xabc').props.children).toBe( + '-0.01 USDT', + ); + expect(getByTestId('avatar-token-USDT')).toBeOnTheScreen(); + + jest.mocked(useTokensData).mockReturnValue({}); + }); + + it('renders a lending-deposit amount from adapter-provided decimals without an API lookup', () => { + // When the adapter already resolved symbol/decimals, the row scales the + // amount without depending on the tokens API (which returns nothing here). + const item = makeItem({ + type: 'lendingDeposit', + status: 'success', + sourceToken: { + amount: '10000', + decimals: 6, + symbol: 'USDC', + direction: 'out', + }, + }); + + const { getByTestId } = render( + , + ); + + expect(getByTestId('activity-primary-amount-0xabc').props.children).toBe( + '-0.01 USDC', + ); + }); + it('renders cross-token bridge as swapped with token pair subtitle', () => { const item = makeItem({ type: 'bridge', diff --git a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts index 25856708fb4..78ed38f6872 100644 --- a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts +++ b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts @@ -25,6 +25,7 @@ import { applyDisplaySign, type ActivityKind, type ActivityListItem, + enrichTokenFromApi, getDisplaySignPrefix, getHumanReadableTokenAmount, isUnlimitedApprovalAmount, @@ -1131,17 +1132,45 @@ export function useActivityListItemRowContent( ) : undefined; + const isLending = + item.type === 'lendingDeposit' || item.type === 'lendingWithdrawal'; + const lendingAssetIds: string[] = []; + if (isLending) { + if ( + 'destinationToken' in item.data && + item.data.destinationToken?.assetId + ) { + lendingAssetIds.push(item.data.destinationToken.assetId); + } + if ('sourceToken' in item.data && item.data.sourceToken?.assetId) { + lendingAssetIds.push(item.data.sourceToken.assetId); + } + } + const lendingTokenData = useTokensData(lendingAssetIds); + const content = resolveCoreContent(item, bridgeHistoryItem); + + let basePrimaryToken: TokenAmount | undefined; + if (isSpendingCap) { + basePrimaryToken = spendingCapToken?.amount ? spendingCapToken : undefined; + } else if (isLending) { + basePrimaryToken = enrichTokenFromApi( + content.primaryToken, + lendingTokenData, + ); + } else { + basePrimaryToken = content.primaryToken; + } const primaryToken = enrichStablecoinTokenMetadata( - isSpendingCap - ? spendingCapToken?.amount - ? spendingCapToken - : undefined - : content.primaryToken, + basePrimaryToken, networkChainId, ); + + const baseSecondaryToken = isLending + ? enrichTokenFromApi(content.secondaryToken, lendingTokenData) + : content.secondaryToken; const secondaryToken = enrichStablecoinTokenMetadata( - content.secondaryToken, + baseSecondaryToken, networkChainId, ); const isPerpsFunding = isPerpsFundingKind(item.type); @@ -1205,12 +1234,18 @@ export function useActivityListItemRowContent( ? getPredictActivity(item)?.icon : undefined; + let avatarTokens: TokenAmount[]; + if (isSpendingCap && spendingCapToken) { + avatarTokens = [spendingCapToken]; + } else if (isLending && primaryToken) { + avatarTokens = [primaryToken]; + } else { + avatarTokens = resolveAvatarTokens(item, bridgeHistoryItem); + } + return { ...content, - avatarTokens: - isSpendingCap && spendingCapToken - ? [spendingCapToken] - : resolveAvatarTokens(item, bridgeHistoryItem), + avatarTokens, avatarIconUrl: predictIconUrl, perpsMarketSymbol, primaryToken, diff --git a/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx b/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx index 39b13135b87..9d7fcedc37f 100644 --- a/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx @@ -10,7 +10,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ScrollView, View } from 'react-native'; import { useSelector } from 'react-redux'; import { strings } from '../../../../../../locales/i18n'; -import Routes from '../../../../../constants/navigation/Routes'; +import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation'; import Engine from '../../../../../core/Engine'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; import { selectCurrentCurrency } from '../../../../../selectors/currencyRateController'; @@ -415,7 +415,7 @@ const EarnLendingDepositConfirmationView = () => { }); // There is variance in when navigation can be called across chains setTimeout(() => { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + navigateToActivityAfterConfirmation(navigation); }, 0); }, ({ transactionMeta }) => transactionMeta.id === transactionId, diff --git a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx index e70f4ee9059..7d16c844929 100644 --- a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx @@ -20,7 +20,7 @@ import Badge, { import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; -import Routes from '../../../../../constants/navigation/Routes'; +import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation'; import { IMetaMetricsEvent, MetaMetricsEvents, @@ -316,7 +316,7 @@ const EarnLendingWithdrawalConfirmationView = () => { }); // There is variance in when navigation can be called across chains setTimeout(() => { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + navigateToActivityAfterConfirmation(navigation); }, 0); }, ({ transactionMeta }) => transactionMeta.id === transactionId, diff --git a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx index 46176b0affe..07143fb7307 100644 --- a/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx +++ b/app/components/UI/Stake/components/StakingConfirmation/ConfirmationFooter/FooterButtonGroup/FooterButtonGroup.tsx @@ -22,7 +22,7 @@ import { FooterButtonGroupActions, FooterButtonGroupProps, } from './FooterButtonGroup.types'; -import Routes from '../../../../../../../constants/navigation/Routes'; +import { navigateToActivityAfterConfirmation } from '../../../../../../../util/navigation/navigateToActivityAfterConfirmation'; import usePoolStakedUnstake from '../../../../hooks/usePoolStakedUnstake'; import { useAnalytics } from '../../../../../../hooks/useAnalytics/useAnalytics'; import { @@ -59,7 +59,6 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => { const { styles } = useStyles(styleSheet, {}); const navigation = useNavigation(); - const { navigate } = navigation; const { trackEvent, createEventBuilder } = useAnalytics(); @@ -117,7 +116,7 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => { () => { submitTxMetaMetric(STAKING_TX_METRIC_EVENTS[action].SUBMITTED); setDidSubmitTransaction(false); - navigate(Routes.TRANSACTIONS_VIEW); + navigateToActivityAfterConfirmation(navigation); }, ({ transactionMeta }) => transactionMeta.id === transactionId, ); @@ -148,7 +147,7 @@ const FooterButtonGroup = ({ valueWei, action }: FooterButtonGroupProps) => { (transactionMeta) => transactionMeta.id === transactionId, ); }, - [action, navigate, submitTxMetaMetric], + [action, navigation, submitTxMetaMetric], ); const handleConfirmation = async () => { diff --git a/app/components/Views/ActivityDetails/templates/SwapDetails.test.tsx b/app/components/Views/ActivityDetails/templates/SwapDetails.test.tsx new file mode 100644 index 00000000000..0ac07ef7279 --- /dev/null +++ b/app/components/Views/ActivityDetails/templates/SwapDetails.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import type { + ActivityListItem, + TokenAmount, +} from '../../../../util/activity-adapters'; +import { useTokensData } from '../../../hooks/useTokensData/useTokensData'; +import { SwapDetails } from './SwapDetails'; + +// Capture the token the amount header receives so we can assert it was enriched +// with decimals before formatting. +let capturedSentToken: TokenAmount | undefined; + +jest.mock('../../../hooks/useTokensData/useTokensData', () => ({ + useTokensData: jest.fn(() => ({})), +})); + +jest.mock('../components', () => ({ + ActivityDetailsBlockExplorerButton: () => null, + ActivityDetailsDoItAgainButton: () => null, + ActivityDetailsFooter: () => null, + ActivityDetailsMetadata: () => null, + ActivityDetailsFeesAndTotal: () => null, + ActivityDetailsDualAmountHeader: ({ + sentToken, + }: { + sentToken?: TokenAmount; + }) => { + capturedSentToken = sentToken; + return null; + }, +})); + +jest.mock('../hooks/useActivityDetailsDoItAgain', () => ({ + useActivityDetailsDoItAgain: () => jest.fn(), + canRenderActivityDetailsDoItAgain: () => false, +})); + +const USDT_ASSET_ID = + 'eip155:42161/erc20:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9'; + +const makeLendingDepositItem = ( + sourceToken: Partial, +): ActivityListItem => + ({ + type: 'lendingDeposit', + chainId: 'eip155:42161', + status: 'success', + timestamp: 1, + hash: '0xabc', + data: { sourceToken }, + }) as unknown as ActivityListItem; + +describe('SwapDetails', () => { + beforeEach(() => { + capturedSentToken = undefined; + jest.mocked(useTokensData).mockReturnValue({}); + }); + + it('enriches the deposited token decimals from the tokens API so the amount is not rendered as raw base units', () => { + // The adapter left `decimals` off the lending sourceToken; without + // enrichment the amount header would format 10000 base units as "10,000" + // instead of 0.01 USDT. + jest.mocked(useTokensData).mockReturnValue({ + [USDT_ASSET_ID]: { + assetId: USDT_ASSET_ID, + symbol: 'USDT', + decimals: 6, + name: 'Tether USD', + iconUrl: '', + }, + }); + + render( + , + ); + + expect(capturedSentToken?.decimals).toBe(6); + expect(capturedSentToken?.symbol).toBe('USDT'); + expect(capturedSentToken?.amount).toBe('10000'); + }); + + it('leaves an already-populated token unchanged (no-op when decimals are present)', () => { + render( + , + ); + + expect(capturedSentToken?.decimals).toBe(6); + }); +}); diff --git a/app/components/Views/ActivityDetails/templates/SwapDetails.tsx b/app/components/Views/ActivityDetails/templates/SwapDetails.tsx index 6dd33d30ce9..4322a576158 100644 --- a/app/components/Views/ActivityDetails/templates/SwapDetails.tsx +++ b/app/components/Views/ActivityDetails/templates/SwapDetails.tsx @@ -1,6 +1,10 @@ import React from 'react'; import { Box, SectionDivider } from '@metamask/design-system-react-native'; -import type { ActivityListItem } from '../../../../util/activity-adapters'; +import { + type ActivityListItem, + enrichTokenFromApi, +} from '../../../../util/activity-adapters'; +import { useTokensData } from '../../../hooks/useTokensData/useTokensData'; import { ActivityDetailsBlockExplorerButton, ActivityDetailsDoItAgainButton, @@ -30,9 +34,17 @@ type SwapDetailsItem = Extract< >; export function SwapDetails({ item }: { item: SwapDetailsItem }) { - const { sourceToken } = item.data; - const destinationToken = + const rawSourceToken = item.data.sourceToken; + const rawDestinationToken = 'destinationToken' in item.data ? item.data.destinationToken : undefined; + + const tokenData = useTokensData( + [rawSourceToken?.assetId, rawDestinationToken?.assetId].filter( + (assetId): assetId is string => Boolean(assetId), + ), + ); + const sourceToken = enrichTokenFromApi(rawSourceToken, tokenData); + const destinationToken = enrichTokenFromApi(rawDestinationToken, tokenData); const totalToken = sourceToken?.amount ? sourceToken : destinationToken; const handleDoItAgain = useActivityDetailsDoItAgain({ sourceToken, diff --git a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts index ceacfd23b54..1b2f271a5b6 100644 --- a/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts +++ b/app/components/Views/confirmations/hooks/transactions/useTransactionConfirm.ts @@ -26,6 +26,7 @@ import { useGaslessSupportedSmartTransactions } from '../gas/useGaslessSupported import { cloneDeep } from 'lodash'; import { useTransactionPayQuotes } from '../pay/useTransactionPayData'; import { useMusdConfirmNavigation } from '../../../../UI/Earn/hooks/useMusdConfirmNavigation'; +import { navigateToActivityAfterConfirmation } from '../../../../../util/navigation/navigateToActivityAfterConfirmation'; import { useFiatConfirm } from '../pay/useFiatConfirm'; import { useHandleHwSend } from '../../../../UI/HardwareWallet/Swaps/useHandleHwSend'; @@ -200,7 +201,7 @@ export function useTransactionConfirm() { isFullScreenConfirmation && !hasTransactionType(transactionMetadata, GO_BACK_TYPES) ) { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + navigateToActivityAfterConfirmation(navigation); } else { navigation.goBack(); } diff --git a/app/components/Views/confirmations/hooks/useConfirmActions.ts b/app/components/Views/confirmations/hooks/useConfirmActions.ts index 2d523de89ba..96308b72b2f 100644 --- a/app/components/Views/confirmations/hooks/useConfirmActions.ts +++ b/app/components/Views/confirmations/hooks/useConfirmActions.ts @@ -5,6 +5,7 @@ import { TransactionType } from '@metamask/transaction-controller'; import PPOMUtil from '../../../../lib/ppom/ppom-util'; import Routes from '../../../../constants/navigation/Routes'; +import { navigateToActivityAfterConfirmation } from '../../../../util/navigation/navigateToActivityAfterConfirmation'; import { MetaMetricsEvents } from '../../../../core/Analytics'; import { isSignatureRequest } from '../utils/confirm'; @@ -76,7 +77,7 @@ export const useConfirmActions = () => { }); if (approvalType === ApprovalType.TransactionBatch) { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + navigateToActivityAfterConfirmation(navigation); } else { navigation.goBack(); } diff --git a/app/util/activity-adapters/activity-list-helpers.test.ts b/app/util/activity-adapters/activity-list-helpers.test.ts index 497d9d1654b..7efe6785857 100644 --- a/app/util/activity-adapters/activity-list-helpers.test.ts +++ b/app/util/activity-adapters/activity-list-helpers.test.ts @@ -1,6 +1,7 @@ -import type { ActivityListItem } from './types'; +import type { ActivityListItem, TokenAmount } from './types'; import { activityMatchesAssetId, + enrichTokenFromApi, formatActivityListDateHeader, getActivityFromTo, getActivityValue, @@ -319,3 +320,82 @@ describe('activity list helpers', () => { ).toBe('eip155:137-contractInteraction-123-0'); }); }); + +describe('enrichTokenFromApi', () => { + const USDT_ASSET_ID = + 'eip155:42161/erc20:0xfd086bc7cd5c481dcc9c85ebe478a1c0b69fcbb9'; + const apiData = { + [USDT_ASSET_ID]: { symbol: 'USDT', decimals: 6 }, + }; + + it('fills missing decimals and symbol from the tokens API', () => { + // Raw base-unit amount with no decimals — without enrichment a formatter + // would render "10000" instead of 0.01. + const token: TokenAmount = { + direction: 'out', + amount: '10000', + assetId: USDT_ASSET_ID, + }; + + expect(enrichTokenFromApi(token, apiData)).toStrictEqual({ + direction: 'out', + amount: '10000', + assetId: USDT_ASSET_ID, + symbol: 'USDT', + decimals: 6, + }); + }); + + it('matches the asset id case-insensitively', () => { + const token: TokenAmount = { + direction: 'out', + amount: '10000', + assetId: USDT_ASSET_ID.toUpperCase(), + }; + + expect(enrichTokenFromApi(token, apiData)?.decimals).toBe(6); + }); + + it('keeps existing symbol and decimals (adapter values win)', () => { + const token: TokenAmount = { + direction: 'out', + amount: '10000', + assetId: USDT_ASSET_ID, + symbol: 'aUSDT', + decimals: 8, + }; + + const result = enrichTokenFromApi(token, apiData); + expect(result?.symbol).toBe('aUSDT'); + expect(result?.decimals).toBe(8); + }); + + it('preserves a zero-decimals value rather than treating it as missing', () => { + const token: TokenAmount = { + direction: 'out', + amount: '5', + assetId: USDT_ASSET_ID, + decimals: 0, + }; + + expect(enrichTokenFromApi(token, apiData)?.decimals).toBe(0); + }); + + it('returns the token unchanged when it has no asset id', () => { + const token: TokenAmount = { direction: 'out', amount: '10000' }; + expect(enrichTokenFromApi(token, apiData)).toBe(token); + }); + + it('returns the token unchanged when the api has no matching entry', () => { + const token: TokenAmount = { + direction: 'out', + amount: '10000', + assetId: 'eip155:1/erc20:0xunknown', + }; + expect(enrichTokenFromApi(token, apiData)).toBe(token); + }); + + it('returns undefined when given no token', () => { + expect(enrichTokenFromApi(undefined, apiData)).toBeUndefined(); + }); +}); diff --git a/app/util/activity-adapters/activity-list-helpers.ts b/app/util/activity-adapters/activity-list-helpers.ts index ae254579801..949cf70c4e0 100644 --- a/app/util/activity-adapters/activity-list-helpers.ts +++ b/app/util/activity-adapters/activity-list-helpers.ts @@ -91,6 +91,26 @@ export const getActivityValue = (item: ActivityListItem) => { return undefined; }; +export function enrichTokenFromApi( + token: TokenAmount | undefined, + dataByAssetId: Record, +): TokenAmount | undefined { + if (!token?.assetId) { + return token; + } + const listToken = dataByAssetId[token.assetId.toLowerCase()]; + if (!listToken) { + return token; + } + const symbol = token.symbol ?? listToken.symbol; + const decimals = token.decimals ?? listToken.decimals; + return { + ...token, + ...(symbol ? { symbol } : {}), + ...(decimals === undefined ? {} : { decimals }), + }; +} + export const getActivityFromTo = (item: ActivityListItem) => { const { data } = item; const rawFrom = (() => { diff --git a/app/util/activity-adapters/adapters/local-transaction.test.ts b/app/util/activity-adapters/adapters/local-transaction.test.ts index 5114f86f2d1..7f616ee0bd4 100644 --- a/app/util/activity-adapters/adapters/local-transaction.test.ts +++ b/app/util/activity-adapters/adapters/local-transaction.test.ts @@ -802,6 +802,247 @@ describe('mapLocalTransaction', () => { }); }); + it('maps a typed lendingDeposit to the underlying token from simulation data', () => { + // Mobile tags lending deposits with TransactionType.lendingDeposit, whose tx + // `to` is the pool — the deposited token must come from the balance change, + // not the pool address (which resolves no symbol/icon). + const transaction = { + chainId: base, + id: 'typed-lending-deposit-id', + hash: '0x1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.lendingDeposit, + txParams: { + from, + to: baseAavePool, + value: '0x0', + }, + simulationData: { + tokenBalanceChanges: [ + { + address: baseUsdc, + difference: '0x186a0', + isDecrease: true, + standard: 'erc20', + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809', + data: { + sourceToken: { + amount: '100000', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a typed lendingDeposit to the underlying token from the outgoing transfer log when no simulation data', () => { + const transaction = { + chainId: base, + hash: '0x2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.lendingDeposit, + txParams: { + from, + to: baseAavePool, + value: '0x0', + }, + txReceipt: { + logs: [ + // Transfer FROM the user (topics[1]) TO the pool (topics[2]) — the + // deposited token. + { + address: baseUsdc, + data: '0x00000000000000000000000000000000000000000000000000000000000186a0', + topics: [ + erc20TransferTopic, + addressTopic(from), + addressTopic(baseAavePool), + ], + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1', + data: { + sourceToken: { + amount: '100000', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('maps a typed lendingDeposit to the underlying token when it is transferred to the reserve aToken, not the pool', () => { + // Aave V3 sends the underlying from the user to the reserve aToken (not the + // pool the tx is addressed to), so the deposit log must still resolve when + // the recipient is not txParams.to. + const baseAToken = '0x4e65fe4dba92790696d040ac24aa414708f5c0ab'; + const transaction = { + chainId: base, + hash: '0x4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1b2c3', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.lendingDeposit, + txParams: { + from, + to: baseAavePool, + value: '0x0', + }, + txReceipt: { + logs: [ + // Transfer FROM the user (topics[1]) TO the reserve aToken (topics[2]), + // which is NOT the pool address the tx was sent to. + { + address: baseUsdc, + data: '0x00000000000000000000000000000000000000000000000000000000000186a0', + topics: [ + erc20TransferTopic, + addressTopic(from), + addressTopic(baseAToken), + ], + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1b2c3', + data: { + sourceToken: { + amount: '100000', + assetId: toAssetId(baseUsdc, 'eip155:8453'), + decimals: 6, + direction: 'out', + symbol: 'USDC', + }, + }, + }); + }); + + it('prefers the outgoing transfer to the pool over an earlier unrelated outgoing transfer (e.g. a gas-fee token)', () => { + const feeToken = '0x1111111111111111111111111111111111111111'; + const paymaster = + '0x0000000000000000000000002222222222222222222222222222222222222222'; + const transaction = { + chainId: base, + hash: '0x5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1b2c3d4', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.lendingDeposit, + txParams: { + from, + to: baseAavePool, + value: '0x0', + }, + txReceipt: { + logs: [ + // An unrelated outgoing transfer from the user (gas-fee token), earlier + // in the log — must NOT be picked as the deposited token. + { + address: feeToken, + data: '0x0000000000000000000000000000000000000000000000000000000000002710', + topics: [erc20TransferTopic, addressTopic(from), paymaster], + }, + // The actual deposit: user -> pool. + { + address: baseUsdc, + data: '0x00000000000000000000000000000000000000000000000000000000000186a0', + topics: [ + erc20TransferTopic, + addressTopic(from), + addressTopic(baseAavePool), + ], + }, + ], + }, + } as unknown as Partial; + + const result = withoutRaw(mapLocalTransaction(makeGroup(transaction))) as { + data: { sourceToken?: { assetId?: string; amount?: string } }; + }; + + expect(result.data.sourceToken?.assetId).toBe( + toAssetId(baseUsdc, 'eip155:8453'), + ); + expect(result.data.sourceToken?.amount).toBe('100000'); + }); + + it('maps a typed lendingDeposit with neither simulation data nor a matching transfer log to the pool-address token (prior behavior)', () => { + const otherSender = + '0x0000000000000000000000001111111111111111111111111111111111111111'; + const transaction = { + chainId: base, + hash: '0x3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1b2', + status: TransactionStatus.confirmed, + time: 1779892154611, + type: TransactionType.lendingDeposit, + txParams: { + from, + to: baseAavePool, + value: '0x0', + }, + txReceipt: { + logs: [ + // A Transfer whose sender isn't the user — not the deposited token. + { + address: baseUsdc, + data: '0x00000000000000000000000000000000000000000000000000000000000186a0', + topics: [erc20TransferTopic, otherSender, addressTopic(from)], + }, + ], + }, + } as unknown as Partial; + + expect( + withoutRaw(mapLocalTransaction(makeGroup(transaction))), + ).toStrictEqual({ + type: 'lendingDeposit', + chainId: 'eip155:8453', + status: 'success', + timestamp: 1779892154611, + hash: '0x3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d5e6f708192a3b4c5d6e7f809a1b2', + data: { + sourceToken: { + assetId: toAssetId(baseAavePool, 'eip155:8453'), + direction: 'out', + }, + }, + }); + }); + it('maps a withdraw contract interaction from the received token transfer', () => { const transaction = { chainId: base, diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts index 3ded3b4a521..270190d0efa 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -279,6 +279,66 @@ export function mapLocalTransaction( }) : undefined; }; + + const getLendingDepositSourceToken = () => { + const suppliedTokenBalanceChange = + initialTransaction.simulationData?.tokenBalanceChanges?.find( + ({ isDecrease, standard }) => isDecrease && standard === 'erc20', + ); + + if (suppliedTokenBalanceChange) { + return getContractToken({ + amount: BigInt(suppliedTokenBalanceChange.difference).toString(), + transaction: initialTransaction, + direction: 'out', + contractAddress: suppliedTokenBalanceChange.address, + }); + } + + const fromAddress = from.toLowerCase(); + const poolAddress = to.toLowerCase(); + const logs = initialTransaction.txReceipt?.logs ?? []; + const isUserOutgoingTransfer = ( + eventTopic: string | undefined, + logFrom: string | undefined, + ): boolean => { + const senderAddress = logFrom + ? `0x${logFrom.slice(-40)}`.toLowerCase() + : undefined; + return ( + eventTopic?.toLowerCase() === environment.tokenTransferLogTopicHash && + senderAddress === fromAddress + ); + }; + const sentTokenLog = + logs.find(({ topics: [eventTopic, logFrom, logTo] = [] }) => { + const recipientAddress = logTo + ? `0x${logTo.slice(-40)}`.toLowerCase() + : undefined; + return ( + isUserOutgoingTransfer(eventTopic, logFrom) && + recipientAddress === poolAddress + ); + }) ?? + logs.find(({ topics: [eventTopic, logFrom] = [] }) => + isUserOutgoingTransfer(eventTopic, logFrom), + ); + + if (sentTokenLog) { + return getContractToken({ + amount: BigInt(String(sentTokenLog.data)).toString(), + transaction: initialTransaction, + direction: 'out', + contractAddress: sentTokenLog.address, + }); + } + + return getContractToken({ + transaction: initialTransaction, + direction: 'out', + contractAddress: initialTransaction.txParams.to, + }); + }; const getDirectWrappedTokenActivity = (): ActivityListItem | undefined => { if (!methodId) { return undefined; @@ -694,11 +754,7 @@ export function mapLocalTransaction( hash, raw: { type: 'localTransaction', data: transactionGroup }, data: { - sourceToken: getContractToken({ - transaction: initialTransaction, - direction: 'out', - contractAddress: initialTransaction.txParams.to, - }), + sourceToken: getLendingDepositSourceToken(), ...(fees ? { fees } : {}), }, }; diff --git a/app/util/activity-adapters/index.ts b/app/util/activity-adapters/index.ts index aaaeb0c6be5..2ebfe6f3177 100644 --- a/app/util/activity-adapters/index.ts +++ b/app/util/activity-adapters/index.ts @@ -36,6 +36,7 @@ export { } from './fiat'; export { activityMatchesAssetId, + enrichTokenFromApi, formatActivityListDateHeader, getActivityFromTo, getActivityValue, diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx new file mode 100644 index 00000000000..a5f5529e728 --- /dev/null +++ b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx @@ -0,0 +1,276 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { + NavigationContainer, + StackActions, + createNavigationContainerRef, + useNavigation, + type NavigationState, + type PartialState, +} from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import Routes from '../../constants/navigation/Routes'; +import { navigateToActivityAfterConfirmation } from './navigateToActivityAfterConfirmation'; + +// Integration test against REAL React Navigation state: it reconstructs each +// flow's stack shape at tx-submission time and asserts that after the redirect +// the user lands on Activity and "back" does not return to the confirmation. + +const RootStack = createNativeStackNavigator(); +const NestedStack = createNativeStackNavigator(); + +const REDESIGNED = Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS; + +const Probe = ({ label }: { label: string }) => {label}; + +// Triggers the redirect helper with its own nested navigation, like the real +// confirmation footers/hooks. +const ConfirmationScreen = () => { + const navigation = useNavigation(); + return ( + navigateToActivityAfterConfirmation(navigation)} + > + confirm + + ); +}; + +const StakeScreensNavigator = () => ( + + + {() => } + + + +); + +const EarnScreensNavigator = () => ( + + + +); + +const SendNavigator = () => ( + + + {() => } + + + {() => } + + + +); + +const renderTree = ( + initialState: PartialState, + ref: ReturnType, +) => + render( + + {/* Activity (TRANSACTIONS_VIEW) is a root route here, as it is when the + Money-account feature is enabled. */} + + + {() => } + + + + + + {() => } + + + , + ); + +// Name of the currently focused leaf route in the whole tree. +const focusedRouteName = ( + ref: ReturnType, +): string | undefined => ref.getCurrentRoute()?.name; + +// Names of the top-level (root) routes, in order. +const rootRouteNames = ( + ref: ReturnType, +): string[] => (ref.getRootState()?.routes ?? []).map((route) => route.name); + +describe('navigateToActivityAfterConfirmation', () => { + it('flow 1 (staking): replaces the flow stack with Activity; back returns to Wallet home', () => { + const ref = createNavigationContainerRef(); + const { getByTestId } = renderTree( + { + routes: [ + { name: Routes.HOME_TABS }, + { + name: 'StakeScreens', + state: { + index: 1, + routes: [{ name: Routes.STAKING.STAKE }, { name: REDESIGNED }], + }, + }, + ], + index: 1, + } as PartialState, + ref, + ); + + fireEvent.press(getByTestId('redirect-trigger')); + + // Whole StakeScreens flow stack replaced (its nested build screen goes too). + expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW); + expect(rootRouteNames(ref)).toEqual([ + Routes.HOME_TABS, + Routes.TRANSACTIONS_VIEW, + ]); + + // Back returns to Wallet home, not the consumed confirmation. + act(() => { + ref.goBack(); + }); + expect(focusedRouteName(ref)).toBe(Routes.HOME_TABS); + }); + + it('flow 2 (lending): replaces only the confirmation stack; back returns to the input screen in the surviving sibling stack', () => { + const ref = createNavigationContainerRef(); + const { getByTestId } = renderTree( + { + routes: [ + { name: Routes.HOME_TABS }, + { + name: 'StakeScreens', + state: { index: 0, routes: [{ name: Routes.STAKING.STAKE }] }, + }, + { + name: Routes.EARN.ROOT, + state: { + index: 0, + routes: [{ name: Routes.EARN.LENDING_DEPOSIT_CONFIRMATION }], + }, + }, + ], + index: 2, + } as PartialState, + ref, + ); + + fireEvent.press(getByTestId('redirect-trigger')); + + // Only EarnScreens (the confirmation) is replaced; the sibling StakeScreens + // input screen survives beneath. + expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW); + expect(rootRouteNames(ref)).toEqual([ + Routes.HOME_TABS, + 'StakeScreens', + Routes.TRANSACTIONS_VIEW, + ]); + + act(() => { + ref.goBack(); + }); + expect(focusedRouteName(ref)).toBe(Routes.STAKING.STAKE); + }); + + it('flow 3 (send): replaces the flow stack with Activity; back returns to Wallet home', () => { + const ref = createNavigationContainerRef(); + const { getByTestId } = renderTree( + { + routes: [ + { name: Routes.HOME_TABS }, + { + name: Routes.SEND.DEFAULT, + state: { + index: 2, + routes: [ + { name: Routes.SEND.AMOUNT }, + { name: Routes.SEND.RECIPIENT }, + { name: REDESIGNED }, + ], + }, + }, + ], + index: 1, + } as PartialState, + ref, + ); + + fireEvent.press(getByTestId('redirect-trigger')); + + // The whole Send flow stack (Amount/Recipient/confirmation) is replaced. + expect(focusedRouteName(ref)).toBe(Routes.TRANSACTIONS_VIEW); + expect(rootRouteNames(ref)).toEqual([ + Routes.HOME_TABS, + Routes.TRANSACTIONS_VIEW, + ]); + + act(() => { + ref.goBack(); + }); + expect(focusedRouteName(ref)).toBe(Routes.HOME_TABS); + }); +}); + +describe('navigateToActivityAfterConfirmation — routing by Activity route location', () => { + const makeNavigation = (routeNames?: string[]) => { + const dispatch = jest.fn(); + const navigate = jest.fn(); + const getParent = + routeNames === undefined + ? () => undefined + : () => ({ getState: () => ({ routeNames }), dispatch }); + return { + navigation: { navigate, getParent } as never, + dispatch, + navigate, + }; + }; + + it('replaces the flow stack when Activity is a root route (Money-account on)', () => { + const { navigation, dispatch, navigate } = makeNavigation([ + Routes.HOME_TABS, + Routes.SEND.DEFAULT, + Routes.TRANSACTIONS_VIEW, + ]); + + navigateToActivityAfterConfirmation(navigation); + + expect(dispatch).toHaveBeenCalledWith( + StackActions.replace(Routes.TRANSACTIONS_VIEW), + ); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('falls back to navigate when Activity is not a root route (Money-account off)', () => { + const { navigation, dispatch, navigate } = makeNavigation([ + Routes.HOME_TABS, + Routes.SEND.DEFAULT, + ]); + + navigateToActivityAfterConfirmation(navigation); + + expect(navigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + expect(dispatch).not.toHaveBeenCalled(); + }); + + it('falls back to navigate when there is no parent navigator', () => { + const { navigation, navigate } = makeNavigation(undefined); + + navigateToActivityAfterConfirmation(navigation); + + expect(navigate).toHaveBeenCalledWith(Routes.TRANSACTIONS_VIEW); + }); +}); diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.ts b/app/util/navigation/navigateToActivityAfterConfirmation.ts new file mode 100644 index 00000000000..a0be48a3e84 --- /dev/null +++ b/app/util/navigation/navigateToActivityAfterConfirmation.ts @@ -0,0 +1,42 @@ +import { + StackActions, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import Routes from '../../constants/navigation/Routes'; + +// The navigation methods this helper needs (Pick avoids coupling to the caller's +// exact NavigationProp variant). +type ActivityRedirectNavigation = Pick< + NavigationProp, + 'navigate' | 'getParent' +>; + +/** + * Redirects to Activity after a transaction, replacing the confirmation's flow + * stack so "back" never lands on the consumed confirmation (which renders an + * infinite loader). One `StackActions.replace` keeps it to a single clean + * transition. + * + * Back destination depends on the flow's stack shape: send/staking replace the + * whole flow stack (→ Wallet home); lending replaces only its own stack, whose + * input screen survives in a sibling stack (→ input screen). + * + * @param navigation - The confirmation screen's navigation object. + */ +export function navigateToActivityAfterConfirmation( + navigation: ActivityRedirectNavigation, +): void { + const rootNavigation = navigation.getParent?.(); + + // `replace` only resolves when Activity is a root-stack screen (Money-account + // enabled — the case with the bug). Otherwise fall back to plain `navigate`. + if ( + rootNavigation?.getState().routeNames.includes(Routes.TRANSACTIONS_VIEW) + ) { + rootNavigation.dispatch(StackActions.replace(Routes.TRANSACTIONS_VIEW)); + return; + } + + navigation.navigate(Routes.TRANSACTIONS_VIEW); +}