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);
+}