Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2032,7 +2032,9 @@ const ALL_KINDS: ActivityListItem['type'][] = [
'swapIncomplete',
'bridge',
'buy',
'rampBuy',
'sell',
'rampSell',
'claim',
'claimMusdBonus',
'deposit',
Expand Down Expand Up @@ -2092,7 +2094,9 @@ const EXPECTED_TITLES = {
swapIncomplete: 'Swapped',
bridge: 'Bridged',
buy: 'Bought',
rampBuy: strings('transactions.interaction'),
sell: 'Sold',
rampSell: strings('transactions.interaction'),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
claim: 'Claimed',
claimMusdBonus: strings('transactions.activity_claim_musd_bonus'),
deposit: 'Deposited',
Expand Down
2 changes: 2 additions & 0 deletions app/components/UI/ActivityListItemRow/resolveIconType.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export function resolveIconType(type: ActivityKind): string {
switch (type) {
case 'send':
case 'sell':
case 'rampSell':
case 'nftSell':
case 'lendingDeposit':
case 'deposit':
Expand All @@ -26,6 +27,7 @@ export function resolveIconType(type: ActivityKind): string {
return 'send';
case 'receive':
case 'buy':
case 'rampBuy':
case 'nftBuy':
case 'claim':
case 'unstake':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ describe('useFormatActivityTokenAmount', () => {
).toBe(strings('confirm.unlimited'));
});

it('falls back to the symbol when there is no amount', () => {
it('treats a missing amount with symbol as zero', () => {
expect(
formatToken({
symbol: 'ETH',
direction: 'out',
} as TokenAmount),
).toBe('ETH');
).toBe('-0 ETH');
});

it('prefixes outgoing amounts with a minus sign', () => {
Expand Down
5 changes: 5 additions & 0 deletions app/components/Views/ActivityList/ActivityList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,10 @@ const ActivityList = forwardRef<ActivityListHandle, ActivityListProps>(
const nonEvmItems = mapNonEvmTransactions(
filteredNonEvmForMalicious,
getBridgeHistoryItemByHash,
(transaction) =>
selectedAccountGroupInternalAccounts.find(
(account) => account.id === transaction.account,
)?.address,
);

// Drop confirmed copies whose local copy won above, so the winning local
Expand Down Expand Up @@ -550,6 +554,7 @@ const ActivityList = forwardRef<ActivityListHandle, ActivityListProps>(
relatedChainIdsByTransactionId,
maliciousTokenKeys,
isPerpsEnabled,
selectedAccountGroupInternalAccounts,
]);

const data = useMemo<ActivityListItem[]>(() => {
Expand Down
126 changes: 126 additions & 0 deletions app/components/Views/ActivityList/helpers/apply-bridge-quote.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { StatusTypes } from '@metamask/bridge-controller';
import type { BridgeHistoryItem } from '@metamask/bridge-status-controller';
import {
TransactionStatus,
TransactionType,
type Transaction,
} from '@metamask/keyring-api';
import { mapKeyringTransaction } from '@metamask/client-utils';
import type { ActivityListItem } from '../../../../util/activity-adapters';
import { applyBridgeQuote } from './apply-bridge-quote';

const solanaChainId =
'solana:4sGjMW1sUnHzSxGspuhpqLDx6wiyjNtZ' as Transaction['chain'];

describe('applyBridgeQuote', () => {
const makeKeyringTx = (overrides: Partial<Transaction> = {}): Transaction =>
({
id: 'bridge-id',
chain: solanaChainId,
account: '00000000-0000-4000-8000-000000000000',
status: TransactionStatus.Confirmed,
timestamp: 1716367781,
type: TransactionType.Send,
from: [
{
address: 'from-address',
asset: {
fungible: true,
type: `${solanaChainId}/slip44:501`,
unit: 'SOL',
amount: '1',
},
},
],
to: [{ address: 'to-address', asset: null }],
fees: [],
events: [],
...overrides,
}) as Transaction;

const makeBridgeHistory = ({
destChainId = 1,
bridgeStatus = StatusTypes.PENDING,
destChainAmount,
}: {
destChainId?: string | number;
bridgeStatus?: StatusTypes;
destChainAmount?: string;
} = {}): BridgeHistoryItem =>
({
quote: {
srcChainId: solanaChainId,
destChainId,
srcAsset: {
assetId: `${solanaChainId}/slip44:501`,
decimals: 9,
symbol: 'SOL',
},
srcTokenAmount: '1000000000',
destAsset: {
assetId: 'eip155:1/slip44:60',
decimals: 18,
symbol: 'ETH',
},
destTokenAmount: '5000000000000000',
},
status: {
status: bridgeStatus,
srcChain: { txHash: 'bridge-id' },
...(destChainAmount
? { destChain: { txHash: '0xdest', amount: destChainAmount } }
: {}),
},
}) as unknown as BridgeHistoryItem;

it('maps a cross-chain bridge to a pending bridge item with quote-derived tokens', () => {
const item = applyBridgeQuote(
mapKeyringTransaction({
transaction: makeKeyringTx(),
}) as ActivityListItem,
makeBridgeHistory(),
'from-address',
);

expect(item).toMatchObject({
type: 'bridge',
status: 'pending',
data: {
from: 'from-address',
sourceToken: { symbol: 'SOL', direction: 'out' },
destinationToken: { symbol: 'ETH', direction: 'in' },
},
});
});

it('marks the bridge successful once the destination leg lands', () => {
const item = applyBridgeQuote(
mapKeyringTransaction({
transaction: makeKeyringTx({ type: TransactionType.Swap }),
}) as ActivityListItem,
makeBridgeHistory({
bridgeStatus: StatusTypes.COMPLETE,
destChainAmount: '4990000000000000',
}),
);

expect(item).toMatchObject({
type: 'bridge',
status: 'success',
data: {
destinationToken: { amount: '4990000000000000' },
},
});
});

it('leaves same-chain swaps with bridge history on the regular keyring mapping', () => {
const item = applyBridgeQuote(
mapKeyringTransaction({
transaction: makeKeyringTx({ type: TransactionType.Swap }),
}) as ActivityListItem,
makeBridgeHistory({ destChainId: solanaChainId }),
);

expect(item).toMatchObject({ type: 'swap', status: 'success' });
});
});
76 changes: 76 additions & 0 deletions app/components/Views/ActivityList/helpers/apply-bridge-quote.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {
isCrossChain,
StatusTypes as BridgeStatusTypes,
} from '@metamask/bridge-controller';
import type { BridgeHistoryItem } from '@metamask/bridge-status-controller';
import type { ActivityListItem } from '../../../../util/activity-adapters';

function getBridgeActivityStatus(bridgeHistoryItem: BridgeHistoryItem) {
const {
quote,
status: { status },
} = bridgeHistoryItem;

if (status === BridgeStatusTypes.FAILED) {
return 'failed';
}

if (status === BridgeStatusTypes.COMPLETE) {
return 'success';
}

if (
isCrossChain(quote.srcChainId, quote.destChainId) &&
(status === BridgeStatusTypes.PENDING ||
status === BridgeStatusTypes.SUBMITTED)
) {
return 'pending';
}

return undefined;
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function applyBridgeQuote(
activity: ActivityListItem,
bridgeHistory?: BridgeHistoryItem,
subjectAddress?: string,
) {
const quote = bridgeHistory?.quote;
if (
!bridgeHistory ||
!quote ||
!isCrossChain(quote.srcChainId, quote.destChainId)
) {
return activity;
}

const fees = 'fees' in activity.data ? activity.data.fees : undefined;
const status =
activity.status === 'failed'
? 'failed'
: getBridgeActivityStatus(bridgeHistory);

return {
...activity,
type: 'bridge',
...(status ? { status } : {}),
data: {
from: subjectAddress,
sourceToken: {
amount: quote.srcTokenAmount,
assetId: quote.srcAsset.assetId,
decimals: quote.srcAsset.decimals,
direction: 'out',
symbol: quote.srcAsset.symbol,
},
destinationToken: {
amount: bridgeHistory.status.destChain?.amount ?? quote.destTokenAmount,
assetId: quote.destAsset.assetId,
decimals: quote.destAsset.decimals,
direction: 'in',
symbol: quote.destAsset.symbol,
},
...(fees === undefined ? {} : { fees }),
},
} as ActivityListItem;
}
32 changes: 25 additions & 7 deletions app/components/Views/ActivityList/helpers/transformations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,23 @@
* Produces ActivityListItem[] from API EVM and non-EVM transaction sources.
* Local EVM transactions are handled separately by useLocalActivityItems.
*/
import { mapKeyringTransaction } from '@metamask/client-utils';

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Point of interest

import {
type V1TransactionByHashResponse,
type V4MultiAccountTransactionsResponse,
} from '@metamask/core-backend';
import { isCrossChain } from '@metamask/bridge-controller';
import type { BridgeHistoryItem } from '@metamask/bridge-status-controller';
import type { Transaction as NonEvmTransaction } from '@metamask/keyring-api';
import type { InfiniteData } from '@tanstack/react-query';
import {
mapApiEvmTransactions,
mapKeyringTransaction,
type ActivityListItem,
type ActivityAdapterEnvironment,
} from '../../../../util/activity-adapters';
import { mergeActivityItems } from '../../../../util/activity-adapters/adapters/dedup';
import { equalsIgnoreCase } from '../../../../util/string';
import { applyBridgeQuote } from './apply-bridge-quote';

export type { ActivityListItem };

Expand Down Expand Up @@ -187,13 +189,29 @@ export function selectApiEvmTransactions({
export function mapNonEvmTransactions(
transactions: NonEvmTransaction[],
getBridgeHistoryItem?: (txId: string) => BridgeHistoryItem | undefined,
getSubjectAddress?: (transaction: NonEvmTransaction) => string | undefined,
): ActivityListItem[] {
return transactions.map((transaction) =>
mapKeyringTransaction({
transaction,
bridgeHistory: getBridgeHistoryItem?.(transaction.id),
}),
);
return transactions.map((transaction) => {
const subjectAddress = getSubjectAddress?.(transaction);
const activity = {
...mapKeyringTransaction({
transaction: {
...transaction,
fees: transaction.fees ?? [],
},
subjectAddress,
}),
raw: { type: 'keyringTransaction' as const, data: transaction },
} as ActivityListItem;
const bridgeHistoryItem = getBridgeHistoryItem?.(transaction.id);
const quote = bridgeHistoryItem?.quote;

if (quote && isCrossChain(quote.srcChainId, quote.destChainId)) {
return applyBridgeQuote(activity, bridgeHistoryItem, subjectAddress);
}

return activity;
});
}

/**
Expand Down
Loading
Loading