From 524410d759806b8a34ebc9c00f0b04e442f456dc Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Fri, 10 Jul 2026 17:21:52 -0600 Subject: [PATCH 1/8] fix(activity): show token icon and amount for lending deposit rows Lending deposit rows derived their token from the pool contract (txParams.to) instead of the underlying deposited asset, so the Activity row had no resolvable icon, symbol, or amount. Resolve the supplied token from simulation data / the outgoing Transfer log (falling back to the pool address), and enrich lending token metadata from the tokens API so the row shows the correct avatar and amount. TMCU-1082 --- .../ActivityListItemRow.test.tsx | 58 ++++++++ .../useActivityListItemRowContent.ts | 48 +++++- .../adapters/local-transaction.test.ts | 140 ++++++++++++++++++ .../adapters/local-transaction.ts | 58 +++++++- 4 files changed, 296 insertions(+), 8 deletions(-) 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..d6f80a09749 100644 --- a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts +++ b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts @@ -371,6 +371,26 @@ function enrichSpendingCapToken( }; } +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 }), + }; +} + function areSameDisplayToken( sourceToken: TokenAmount | undefined, destinationToken: TokenAmount | undefined, @@ -1131,17 +1151,37 @@ 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); const primaryToken = enrichStablecoinTokenMetadata( isSpendingCap ? spendingCapToken?.amount ? spendingCapToken : undefined - : content.primaryToken, + : isLending + ? enrichTokenFromApi(content.primaryToken, lendingTokenData) + : content.primaryToken, networkChainId, ); const secondaryToken = enrichStablecoinTokenMetadata( - content.secondaryToken, + isLending + ? enrichTokenFromApi(content.secondaryToken, lendingTokenData) + : content.secondaryToken, networkChainId, ); const isPerpsFunding = isPerpsFundingKind(item.type); @@ -1210,7 +1250,9 @@ export function useActivityListItemRowContent( avatarTokens: isSpendingCap && spendingCapToken ? [spendingCapToken] - : resolveAvatarTokens(item, bridgeHistoryItem), + : isLending && primaryToken + ? [primaryToken] + : resolveAvatarTokens(item, bridgeHistoryItem), avatarIconUrl: predictIconUrl, perpsMarketSymbol, primaryToken, diff --git a/app/util/activity-adapters/adapters/local-transaction.test.ts b/app/util/activity-adapters/adapters/local-transaction.test.ts index 5114f86f2d1..39d48e96e2b 100644 --- a/app/util/activity-adapters/adapters/local-transaction.test.ts +++ b/app/util/activity-adapters/adapters/local-transaction.test.ts @@ -802,6 +802,146 @@ 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 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..da42925312f 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -279,6 +279,58 @@ export function mapLocalTransaction( }) : undefined; }; + // The deposited token is the underlying asset (USDC/USDT/…), NOT the pool the + // tx is sent to. Deriving the token from `txParams.to` (the pool) leaves the + // row with no resolvable symbol/icon and no amount, so resolve the real token + // from simulation data first, then the outgoing Transfer log, and only fall + // back to the pool address to preserve the prior behavior. + 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, + }); + } + + // Post-confirmation fallback: the outgoing Transfer log (token sent FROM the + // user). Mirrors getLendingWithdrawalDestinationToken but matches on the + // sender (topics[1]) instead of the recipient (topics[2]). + const fromAddress = from.toLowerCase(); + const sentTokenLog = (initialTransaction.txReceipt?.logs ?? []).find( + ({ topics: [eventTopic, logFrom] = [] }) => { + const senderAddress = logFrom + ? `0x${logFrom.slice(-40)}`.toLowerCase() + : undefined; + + return ( + eventTopic?.toLowerCase() === environment.tokenTransferLogTopicHash && + senderAddress === fromAddress + ); + }, + ); + + 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 +746,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 } : {}), }, }; From 390d48791bdb9e34ccacd4964fa3ff1876914d8c Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Fri, 10 Jul 2026 17:22:37 -0600 Subject: [PATCH 2/8] fix(confirmations): avoid stale confirmation on back from Activity after a transaction After a full-screen confirmation submitted, the redirect pushed the Activity screen on top of the now-consumed confirmation (approval deleted via deleteAfterResult), so pressing back returned to it and it rendered an infinite loader. A new navigateToActivityAfterConfirmation helper replaces the confirmation's stack with Activity in a single StackActions.replace, avoiding the stale screen and the pop+push double-animation. Wired into the shared transaction confirm hook, batch approvals, the Earn lending deposit/withdrawal views, and the legacy staking footer. TMCU-1001 --- .../index.tsx | 3 +- .../index.tsx | 3 +- .../FooterButtonGroup/FooterButtonGroup.tsx | 7 +- .../transactions/useTransactionConfirm.ts | 3 +- .../confirmations/hooks/useConfirmActions.ts | 3 +- ...vigateToActivityAfterConfirmation.test.tsx | 233 ++++++++++++++++++ .../navigateToActivityAfterConfirmation.ts | 54 ++++ 7 files changed, 298 insertions(+), 8 deletions(-) create mode 100644 app/util/navigation/navigateToActivityAfterConfirmation.test.tsx create mode 100644 app/util/navigation/navigateToActivityAfterConfirmation.ts diff --git a/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx b/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx index 39b13135b87..b216abb1d98 100644 --- a/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx @@ -11,6 +11,7 @@ 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 +416,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..350ba188b98 100644 --- a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx @@ -21,6 +21,7 @@ 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 +317,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/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/navigation/navigateToActivityAfterConfirmation.test.tsx b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx new file mode 100644 index 00000000000..209f8d4c34c --- /dev/null +++ b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx @@ -0,0 +1,233 @@ +import React from 'react'; +import { Text } from 'react-native'; +import { render, fireEvent, act } from '@testing-library/react-native'; +import { + NavigationContainer, + 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'; + +// This is an integration test against REAL React Navigation state (not mocked), +// because the fix depends on how the confirmation pop and the Activity push +// interact across nested navigators (and the ordering differs by stack shape). +// It reconstructs the exact stack shape each flow has at transaction-submission +// time (see the flow traces in the PR description) and asserts that after the +// redirect: +// 1. the user lands on the Activity screen, and +// 2. pressing "back" returns to the transaction-building screen, not the +// consumed confirmation. + +const RootStack = createNativeStackNavigator(); +const NestedStack = createNativeStackNavigator(); + +const REDESIGNED = Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS; + +const Probe = ({ label }: { label: string }) => {label}; + +// A confirmation screen whose only job is to trigger the redirect helper with +// its own (nested) navigation object — mirroring how the real confirmation +// footers/hooks call it. +const ConfirmationScreen = () => { + const navigation = useNavigation(); + return ( + navigateToActivityAfterConfirmation(navigation)} + > + confirm + + ); +}; + +const StakeScreensNavigator = () => ( + + + {() => } + + + +); + +const EarnScreensNavigator = () => ( + + + +); + +const SendNavigator = () => ( + + + {() => } + + + {() => } + + + +); + +const renderTree = ( + initialState: PartialState, + ref: ReturnType, +) => + render( + + {/* TRANSACTIONS_VIEW is registered at the root level, as it is when the + Money-account feature is enabled — this is what lets a bare + navigate(TRANSACTIONS_VIEW) push Activity on top of a confirmation. */} + + + {() => } + + + + + + {() => } + + + , + ); + +// 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')); + + // The whole StakeScreens flow stack is replaced by Activity (the build + // screen is nested inside it, so it goes with it). + 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 the EarnScreens stack (which held just the confirmation) is replaced; + // the sibling StakeScreens stack with the 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); + }); +}); diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.ts b/app/util/navigation/navigateToActivityAfterConfirmation.ts new file mode 100644 index 00000000000..6329a90c687 --- /dev/null +++ b/app/util/navigation/navigateToActivityAfterConfirmation.ts @@ -0,0 +1,54 @@ +import { + StackActions, + type NavigationProp, + type ParamListBase, +} from '@react-navigation/native'; +import Routes from '../../constants/navigation/Routes'; + +// Only the navigation methods this helper needs. Using Pick avoids coupling to +// the caller's exact NavigationProp variant (hooks override `getState`, class +// props differ, etc.). +type ActivityRedirectNavigation = Pick< + NavigationProp, + 'navigate' | 'getParent' +>; + +/** + * Redirects to the Activity screen after a transaction is submitted, dropping + * the now-consumed confirmation from the back stack so "back" from Activity does + * not land on the stale confirmation (whose approval was consumed via + * `deleteAfterResult`, so revisiting it renders an infinite loader). + * + * The confirmation always lives in a nested stack that is a direct child of the + * root navigator (the flow stack: Send, StakeScreens, EarnScreens…). Replacing + * that whole stack with Activity, in one `StackActions.replace`, is a single + * clean transition — no pop-then-push double animation, no half-finished pop + * flashing on back. Native-stack can't cleanly remove a nested confirmation while + * keeping its sibling build screen AND push a root-level Activity in one move, so + * we replace the stack instead. What "back" lands on then depends on the flow's + * shape: + * + * Same stack as the build screen (send, staking): the whole flow stack is + * replaced, so "back" returns to Wallet home. + * + * Sole screen of its own root-sibling stack (lending): only the confirmation's + * stack is replaced; its input screen lives in a sibling stack that survives, so + * "back" returns to that input screen. + * + * Falls back to a plain `navigate` when the parent tree can't be resolved. See the + * real-navigator integration tests in `navigateToActivityAfterConfirmation.test.tsx`. + * + * @param navigation - The confirmation screen's navigation object. + */ +export function navigateToActivityAfterConfirmation( + navigation: ActivityRedirectNavigation, +): void { + const rootNavigation = navigation.getParent?.(); + + if (!rootNavigation) { + navigation.navigate(Routes.TRANSACTIONS_VIEW); + return; + } + + rootNavigation.dispatch(StackActions.replace(Routes.TRANSACTIONS_VIEW)); +} From 4f3bfab8d95791eef046665d1839613701ccc15e Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 09:49:37 -0600 Subject: [PATCH 3/8] fix(confirmations): only replace the flow stack when Activity is a root route StackActions.replace(TRANSACTIONS_VIEW) only resolves when Activity is registered on the root navigator (Money-account enabled). When the flag is off, Activity lives under the tabs, so guard on the root routeNames and fall back to a plain navigate() to match the prior behavior. Adds unit coverage for the replace / navigate / no-parent branches. Addresses Cursor Bugbot review. TMCU-1001 --- ...vigateToActivityAfterConfirmation.test.tsx | 52 +++++++++++++++++++ .../navigateToActivityAfterConfirmation.ts | 13 +++-- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx index 209f8d4c34c..b9d09aa1193 100644 --- a/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx +++ b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx @@ -3,6 +3,7 @@ import { Text } from 'react-native'; import { render, fireEvent, act } from '@testing-library/react-native'; import { NavigationContainer, + StackActions, createNavigationContainerRef, useNavigation, type NavigationState, @@ -231,3 +232,54 @@ describe('navigateToActivityAfterConfirmation', () => { 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 index 6329a90c687..b6f1c5d4be4 100644 --- a/app/util/navigation/navigateToActivityAfterConfirmation.ts +++ b/app/util/navigation/navigateToActivityAfterConfirmation.ts @@ -45,10 +45,17 @@ export function navigateToActivityAfterConfirmation( ): void { const rootNavigation = navigation.getParent?.(); - if (!rootNavigation) { - navigation.navigate(Routes.TRANSACTIONS_VIEW); + // `StackActions.replace(TRANSACTIONS_VIEW)` only works when Activity is a + // screen registered on the root navigator — which it is when the Money-account + // feature is enabled (the case that produces the stale-confirmation bug). When + // it isn't (flag off, Activity lives under the tabs) `replace` would not + // resolve, so fall back to a plain `navigate`, matching the previous behavior. + if ( + rootNavigation?.getState().routeNames.includes(Routes.TRANSACTIONS_VIEW) + ) { + rootNavigation.dispatch(StackActions.replace(Routes.TRANSACTIONS_VIEW)); return; } - rootNavigation.dispatch(StackActions.replace(Routes.TRANSACTIONS_VIEW)); + navigation.navigate(Routes.TRANSACTIONS_VIEW); } From 57b3da1b4f0707634683b5ae9e818d70dd0ec3ba Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 09:50:06 -0600 Subject: [PATCH 4/8] fix(activity): require pool recipient when resolving lending deposit token from logs The post-confirmation receipt-log fallback matched the first outgoing ERC-20 Transfer sent from the user, which could pick an unrelated transfer (e.g. a gas-fee token) earlier in the log. Also require the recipient to be the pool (txParams.to) so it matches the actual deposit transfer. Addresses Cursor Bugbot review. TMCU-1082 --- .../adapters/local-transaction.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts index da42925312f..e0aa2e2f0da 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -299,19 +299,25 @@ export function mapLocalTransaction( }); } - // Post-confirmation fallback: the outgoing Transfer log (token sent FROM the - // user). Mirrors getLendingWithdrawalDestinationToken but matches on the - // sender (topics[1]) instead of the recipient (topics[2]). + // Post-confirmation fallback: the outgoing Transfer log for the deposit — + // sent FROM the user (topics[1]) TO the pool (topics[2] === txParams.to). + // Requiring the pool recipient avoids matching an unrelated outgoing + // transfer from the same account (e.g. a gas-fee token) earlier in the log. const fromAddress = from.toLowerCase(); + const poolAddress = to.toLowerCase(); const sentTokenLog = (initialTransaction.txReceipt?.logs ?? []).find( - ({ topics: [eventTopic, logFrom] = [] }) => { + ({ topics: [eventTopic, logFrom, logTo] = [] }) => { const senderAddress = logFrom ? `0x${logFrom.slice(-40)}`.toLowerCase() : undefined; + const recipientAddress = logTo + ? `0x${logTo.slice(-40)}`.toLowerCase() + : undefined; return ( eventTopic?.toLowerCase() === environment.tokenTransferLogTopicHash && - senderAddress === fromAddress + senderAddress === fromAddress && + recipientAddress === poolAddress ); }, ); From 8bea2d5be2c1f9be4abc2769f62539dc48506a45 Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 10:30:58 -0600 Subject: [PATCH 5/8] fix(activity): prefer pool recipient but fall back to first outgoing transfer for lending deposit token Requiring the Transfer recipient to equal the pool (txParams.to) missed the real deposit log for Aave V3, which sends the underlying from the user to the reserve aToken rather than to the pool address. Instead, prefer a transfer to the pool when present (to disambiguate from an unrelated outgoing transfer such as a gas-fee token) and otherwise fall back to the first outgoing transfer from the user. Addresses the follow-up Cursor Bugbot finding. TMCU-1082 --- .../adapters/local-transaction.test.ts | 101 ++++++++++++++++++ .../adapters/local-transaction.ts | 38 ++++--- 2 files changed, 124 insertions(+), 15 deletions(-) diff --git a/app/util/activity-adapters/adapters/local-transaction.test.ts b/app/util/activity-adapters/adapters/local-transaction.test.ts index 39d48e96e2b..7f616ee0bd4 100644 --- a/app/util/activity-adapters/adapters/local-transaction.test.ts +++ b/app/util/activity-adapters/adapters/local-transaction.test.ts @@ -899,6 +899,107 @@ describe('mapLocalTransaction', () => { }); }); + 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'; diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts index e0aa2e2f0da..3836b072237 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -299,28 +299,36 @@ export function mapLocalTransaction( }); } - // Post-confirmation fallback: the outgoing Transfer log for the deposit — - // sent FROM the user (topics[1]) TO the pool (topics[2] === txParams.to). - // Requiring the pool recipient avoids matching an unrelated outgoing - // transfer from the same account (e.g. a gas-fee token) earlier in the log. + // Post-confirmation fallback: the outgoing Transfer log for the deposit + // (sent FROM the user, topics[1]). Prefer the transfer whose recipient + // (topics[2]) is the pool (txParams.to) when present — this disambiguates + // from an unrelated outgoing transfer such as a gas-fee token. Otherwise fall + // back to the first outgoing transfer from the user: Aave V3 sends the + // underlying to the reserve aToken, not the pool address, so a strict + // pool-recipient match would miss the real deposit log. const fromAddress = from.toLowerCase(); const poolAddress = to.toLowerCase(); - const sentTokenLog = (initialTransaction.txReceipt?.logs ?? []).find( - ({ topics: [eventTopic, logFrom, logTo] = [] }) => { - const senderAddress = logFrom - ? `0x${logFrom.slice(-40)}`.toLowerCase() - : undefined; + const logs = initialTransaction.txReceipt?.logs ?? []; + const isUserOutgoingTransfer = (topics: string[] = []): boolean => { + const [eventTopic, logFrom] = topics; + const senderAddress = logFrom + ? `0x${logFrom.slice(-40)}`.toLowerCase() + : undefined; + return ( + eventTopic?.toLowerCase() === environment.tokenTransferLogTopicHash && + senderAddress === fromAddress + ); + }; + const sentTokenLog = + logs.find(({ topics = [] }) => { + const logTo = topics[2]; const recipientAddress = logTo ? `0x${logTo.slice(-40)}`.toLowerCase() : undefined; - return ( - eventTopic?.toLowerCase() === environment.tokenTransferLogTopicHash && - senderAddress === fromAddress && - recipientAddress === poolAddress + isUserOutgoingTransfer(topics) && recipientAddress === poolAddress ); - }, - ); + }) ?? logs.find(({ topics = [] }) => isUserOutgoingTransfer(topics)); if (sentTokenLog) { return getContractToken({ From d52c1d605a4b633b0c5b5ce41872ac3f8b585056 Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 11:59:24 -0600 Subject: [PATCH 6/8] fix(activity): apply token decimals to lending amounts on the Activity details screen The Activity transaction details screen (SwapDetails template) rendered the lending deposit amount and its fiat total from the raw token without applying decimals (e.g. -10,000 / $9,991.95 instead of -0.01 / ~$0.01), because it never enriched the token's decimals the way the Activity list row does. Enrich the source/destination token decimals from the tokens API in SwapDetails so both the amount and the fiat total resolve correctly. Moves the shared enrichTokenFromApi helper into activity-list-helpers (re-exported from the package index) with direct unit tests, and adds SwapDetails coverage. TMCU-1082 --- .../useActivityListItemRowContent.ts | 21 +--- .../templates/SwapDetails.test.tsx | 108 ++++++++++++++++++ .../ActivityDetails/templates/SwapDetails.tsx | 18 ++- .../activity-list-helpers.test.ts | 82 ++++++++++++- .../activity-list-helpers.ts | 20 ++++ app/util/activity-adapters/index.ts | 1 + 6 files changed, 226 insertions(+), 24 deletions(-) create mode 100644 app/components/Views/ActivityDetails/templates/SwapDetails.test.tsx diff --git a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts index d6f80a09749..b013aac1f51 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, @@ -371,26 +372,6 @@ function enrichSpendingCapToken( }; } -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 }), - }; -} - function areSameDisplayToken( sourceToken: TokenAmount | undefined, destinationToken: TokenAmount | undefined, 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/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/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, From 1130f34b31b2199a9565fd78d2f8aaa6996775c6 Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 13:49:54 -0600 Subject: [PATCH 7/8] fix(activity): resolve SonarCloud nested-ternary, unused-import, and lint:tsc findings - Extract the nested ternaries for the primary token and avatar tokens in useActivityListItemRowContent into if/else statements (SonarCloud). - Remove the now-unused Routes imports from the Earn lending deposit/withdrawal views (SonarCloud). - Fix the lint:tsc error in the lending deposit receipt-log fallback by destructuring the topic elements instead of passing the whole topics array to the helper. --- .../useActivityListItemRowContent.ts | 44 ++++++++++++------- .../index.tsx | 1 - .../index.tsx | 1 - .../adapters/local-transaction.ts | 17 ++++--- 4 files changed, 39 insertions(+), 24 deletions(-) diff --git a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts index b013aac1f51..78ed38f6872 100644 --- a/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts +++ b/app/components/UI/ActivityListItemRow/useActivityListItemRowContent.ts @@ -1149,20 +1149,28 @@ export function useActivityListItemRowContent( 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 - : isLending - ? enrichTokenFromApi(content.primaryToken, lendingTokenData) - : content.primaryToken, + basePrimaryToken, networkChainId, ); + + const baseSecondaryToken = isLending + ? enrichTokenFromApi(content.secondaryToken, lendingTokenData) + : content.secondaryToken; const secondaryToken = enrichStablecoinTokenMetadata( - isLending - ? enrichTokenFromApi(content.secondaryToken, lendingTokenData) - : content.secondaryToken, + baseSecondaryToken, networkChainId, ); const isPerpsFunding = isPerpsFundingKind(item.type); @@ -1226,14 +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] - : isLending && primaryToken - ? [primaryToken] - : 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 b216abb1d98..9d7fcedc37f 100644 --- a/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingDepositConfirmationView/index.tsx @@ -10,7 +10,6 @@ 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'; diff --git a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx index 350ba188b98..7d16c844929 100644 --- a/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx +++ b/app/components/UI/Earn/Views/EarnLendingWithdrawalConfirmationView/index.tsx @@ -20,7 +20,6 @@ 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, diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts index 3836b072237..600368a09fa 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -309,8 +309,10 @@ export function mapLocalTransaction( const fromAddress = from.toLowerCase(); const poolAddress = to.toLowerCase(); const logs = initialTransaction.txReceipt?.logs ?? []; - const isUserOutgoingTransfer = (topics: string[] = []): boolean => { - const [eventTopic, logFrom] = topics; + const isUserOutgoingTransfer = ( + eventTopic: string | undefined, + logFrom: string | undefined, + ): boolean => { const senderAddress = logFrom ? `0x${logFrom.slice(-40)}`.toLowerCase() : undefined; @@ -320,15 +322,18 @@ export function mapLocalTransaction( ); }; const sentTokenLog = - logs.find(({ topics = [] }) => { - const logTo = topics[2]; + logs.find(({ topics: [eventTopic, logFrom, logTo] = [] }) => { const recipientAddress = logTo ? `0x${logTo.slice(-40)}`.toLowerCase() : undefined; return ( - isUserOutgoingTransfer(topics) && recipientAddress === poolAddress + isUserOutgoingTransfer(eventTopic, logFrom) && + recipientAddress === poolAddress ); - }) ?? logs.find(({ topics = [] }) => isUserOutgoingTransfer(topics)); + }) ?? + logs.find(({ topics: [eventTopic, logFrom] = [] }) => + isUserOutgoingTransfer(eventTopic, logFrom), + ); if (sentTokenLog) { return getContractToken({ From 212f87152549b2789c1b234af3439862abe51972 Mon Sep 17 00:00:00 2001 From: vinnyhoward Date: Mon, 13 Jul 2026 14:15:03 -0600 Subject: [PATCH 8/8] fix: clean up comments --- .../adapters/local-transaction.ts | 13 +----- ...vigateToActivityAfterConfirmation.test.tsx | 29 +++++-------- .../navigateToActivityAfterConfirmation.ts | 41 +++++-------------- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/app/util/activity-adapters/adapters/local-transaction.ts b/app/util/activity-adapters/adapters/local-transaction.ts index 600368a09fa..270190d0efa 100644 --- a/app/util/activity-adapters/adapters/local-transaction.ts +++ b/app/util/activity-adapters/adapters/local-transaction.ts @@ -279,11 +279,7 @@ export function mapLocalTransaction( }) : undefined; }; - // The deposited token is the underlying asset (USDC/USDT/…), NOT the pool the - // tx is sent to. Deriving the token from `txParams.to` (the pool) leaves the - // row with no resolvable symbol/icon and no amount, so resolve the real token - // from simulation data first, then the outgoing Transfer log, and only fall - // back to the pool address to preserve the prior behavior. + const getLendingDepositSourceToken = () => { const suppliedTokenBalanceChange = initialTransaction.simulationData?.tokenBalanceChanges?.find( @@ -299,13 +295,6 @@ export function mapLocalTransaction( }); } - // Post-confirmation fallback: the outgoing Transfer log for the deposit - // (sent FROM the user, topics[1]). Prefer the transfer whose recipient - // (topics[2]) is the pool (txParams.to) when present — this disambiguates - // from an unrelated outgoing transfer such as a gas-fee token. Otherwise fall - // back to the first outgoing transfer from the user: Aave V3 sends the - // underlying to the reserve aToken, not the pool address, so a strict - // pool-recipient match would miss the real deposit log. const fromAddress = from.toLowerCase(); const poolAddress = to.toLowerCase(); const logs = initialTransaction.txReceipt?.logs ?? []; diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx index b9d09aa1193..a5f5529e728 100644 --- a/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx +++ b/app/util/navigation/navigateToActivityAfterConfirmation.test.tsx @@ -13,15 +13,9 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack'; import Routes from '../../constants/navigation/Routes'; import { navigateToActivityAfterConfirmation } from './navigateToActivityAfterConfirmation'; -// This is an integration test against REAL React Navigation state (not mocked), -// because the fix depends on how the confirmation pop and the Activity push -// interact across nested navigators (and the ordering differs by stack shape). -// It reconstructs the exact stack shape each flow has at transaction-submission -// time (see the flow traces in the PR description) and asserts that after the -// redirect: -// 1. the user lands on the Activity screen, and -// 2. pressing "back" returns to the transaction-building screen, not the -// consumed confirmation. +// 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(); @@ -30,9 +24,8 @@ const REDESIGNED = Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS; const Probe = ({ label }: { label: string }) => {label}; -// A confirmation screen whose only job is to trigger the redirect helper with -// its own (nested) navigation object — mirroring how the real confirmation -// footers/hooks call it. +// Triggers the redirect helper with its own nested navigation, like the real +// confirmation footers/hooks. const ConfirmationScreen = () => { const navigation = useNavigation(); return ( @@ -81,9 +74,8 @@ const renderTree = ( ) => render( - {/* TRANSACTIONS_VIEW is registered at the root level, as it is when the - Money-account feature is enabled — this is what lets a bare - navigate(TRANSACTIONS_VIEW) push Activity on top of a confirmation. */} + {/* Activity (TRANSACTIONS_VIEW) is a root route here, as it is when the + Money-account feature is enabled. */} {() => } @@ -139,8 +131,7 @@ describe('navigateToActivityAfterConfirmation', () => { fireEvent.press(getByTestId('redirect-trigger')); - // The whole StakeScreens flow stack is replaced by Activity (the build - // screen is nested inside it, so it goes with it). + // 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, @@ -179,8 +170,8 @@ describe('navigateToActivityAfterConfirmation', () => { fireEvent.press(getByTestId('redirect-trigger')); - // Only the EarnScreens stack (which held just the confirmation) is replaced; - // the sibling StakeScreens stack with the input screen survives beneath. + // 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, diff --git a/app/util/navigation/navigateToActivityAfterConfirmation.ts b/app/util/navigation/navigateToActivityAfterConfirmation.ts index b6f1c5d4be4..a0be48a3e84 100644 --- a/app/util/navigation/navigateToActivityAfterConfirmation.ts +++ b/app/util/navigation/navigateToActivityAfterConfirmation.ts @@ -5,38 +5,22 @@ import { } from '@react-navigation/native'; import Routes from '../../constants/navigation/Routes'; -// Only the navigation methods this helper needs. Using Pick avoids coupling to -// the caller's exact NavigationProp variant (hooks override `getState`, class -// props differ, etc.). +// The navigation methods this helper needs (Pick avoids coupling to the caller's +// exact NavigationProp variant). type ActivityRedirectNavigation = Pick< NavigationProp, 'navigate' | 'getParent' >; /** - * Redirects to the Activity screen after a transaction is submitted, dropping - * the now-consumed confirmation from the back stack so "back" from Activity does - * not land on the stale confirmation (whose approval was consumed via - * `deleteAfterResult`, so revisiting it renders an infinite loader). + * 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. * - * The confirmation always lives in a nested stack that is a direct child of the - * root navigator (the flow stack: Send, StakeScreens, EarnScreens…). Replacing - * that whole stack with Activity, in one `StackActions.replace`, is a single - * clean transition — no pop-then-push double animation, no half-finished pop - * flashing on back. Native-stack can't cleanly remove a nested confirmation while - * keeping its sibling build screen AND push a root-level Activity in one move, so - * we replace the stack instead. What "back" lands on then depends on the flow's - * shape: - * - * Same stack as the build screen (send, staking): the whole flow stack is - * replaced, so "back" returns to Wallet home. - * - * Sole screen of its own root-sibling stack (lending): only the confirmation's - * stack is replaced; its input screen lives in a sibling stack that survives, so - * "back" returns to that input screen. - * - * Falls back to a plain `navigate` when the parent tree can't be resolved. See the - * real-navigator integration tests in `navigateToActivityAfterConfirmation.test.tsx`. + * 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. */ @@ -45,11 +29,8 @@ export function navigateToActivityAfterConfirmation( ): void { const rootNavigation = navigation.getParent?.(); - // `StackActions.replace(TRANSACTIONS_VIEW)` only works when Activity is a - // screen registered on the root navigator — which it is when the Money-account - // feature is enabled (the case that produces the stale-confirmation bug). When - // it isn't (flag off, Activity lives under the tabs) `replace` would not - // resolve, so fall back to a plain `navigate`, matching the previous behavior. + // `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) ) {