Skip to content

Commit 3bb9a7b

Browse files
ghgoodreaumicaelae
andcommitted
feat: track sol bridge txs (#30619)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** Hacky way to display when Solana txs in the activity log are bridges. Polling and more robust code to be added. <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/30619?quickstart=1) ## **Related issues** Fixes: ## **Manual testing steps** 1. Go to bridge page on Solana. 2. Bridge to EVM network. 3. Observe transaction labeled as Bridge populate in activity after it is detected. ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Extension Coding Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Micaela Estabillo <[email protected]>
1 parent f07c666 commit 3bb9a7b

File tree

6 files changed

+391
-107
lines changed

6 files changed

+391
-107
lines changed

ui/components/app/transaction-list/transaction-list.component.js

+107-72
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ import {
2626
getShouldHideZeroBalanceTokens,
2727
///: END:ONLY_INCLUDE_IF
2828
} from '../../../selectors';
29+
///: BEGIN:ONLY_INCLUDE_IF(multichain)
30+
import useSolanaBridgeTransactionMapping from '../../../hooks/bridge/useSolanaBridgeTransactionMapping';
31+
///: END:ONLY_INCLUDE_IF
2932
import { useI18nContext } from '../../../hooks/useI18nContext';
3033
import TransactionListItem from '../transaction-list-item';
3134
import SmartTransactionListItem from '../transaction-list-item/smart-transaction-list-item.component';
@@ -201,6 +204,10 @@ export default function TransactionList({
201204
getSelectedAccountMultichainTransactions,
202205
);
203206

207+
// Use our custom hook to map Solana bridge transactions with destination chain info
208+
const modifiedNonEvmTransactions =
209+
useSolanaBridgeTransactionMapping(nonEvmTransactions);
210+
204211
const isSolanaAccount = useSelector(isSelectedInternalAccountSolana);
205212
///: END:ONLY_INCLUDE_IF
206213

@@ -355,91 +362,119 @@ export default function TransactionList({
355362
<Box className="transaction-list__transactions">
356363
{nonEvmTransactions?.transactions.length > 0 ? (
357364
<Box className="transaction-list__completed-transactions">
358-
{groupNonEvmTransactionsByDate(nonEvmTransactions).map(
359-
(dateGroup) => (
360-
<Fragment key={dateGroup.date}>
361-
<Text
362-
paddingTop={4}
363-
paddingInline={4}
364-
variant={TextVariant.bodyMd}
365-
color={TextColor.textDefault}
366-
>
367-
{dateGroup.date}
368-
</Text>
369-
{dateGroup.transactionGroups.map((transaction, index) => (
370-
<ActivityListItem
371-
key={`${transaction.account}:${index}`}
372-
className="custom-class"
373-
data-testid="activity-list-item"
374-
onClick={() => toggleShowDetails(transaction)}
375-
icon={
376-
<BadgeWrapper
377-
anchorElementShape="circular"
378-
badge={
379-
<AvatarNetwork
380-
borderColor="background-default"
381-
borderWidth={1}
382-
className="activity-tx__network-badge"
383-
data-testid="activity-tx-network-badge"
384-
name={
385-
isSolanaAccount
386-
? MULTICHAIN_PROVIDER_CONFIGS[
387-
MultichainNetworks.SOLANA
388-
].nickname
389-
: MULTICHAIN_PROVIDER_CONFIGS[
390-
MultichainNetworks.BITCOIN
391-
].nickname
392-
}
393-
size="xs"
394-
src={
395-
isSolanaAccount
396-
? SOLANA_TOKEN_IMAGE_URL
397-
: BITCOIN_TOKEN_IMAGE_URL
398-
}
399-
/>
400-
}
401-
display="block"
402-
positionObj={{ right: -4, top: -4 }}
365+
{groupNonEvmTransactionsByDate(
366+
modifiedNonEvmTransactions || nonEvmTransactions,
367+
).map((dateGroup) => (
368+
<Fragment key={dateGroup.date}>
369+
<Text
370+
paddingTop={4}
371+
paddingInline={4}
372+
variant={TextVariant.bodyMd}
373+
color={TextColor.textDefault}
374+
>
375+
{dateGroup.date}
376+
</Text>
377+
{dateGroup.transactionGroups.map((transaction, index) => (
378+
<ActivityListItem
379+
key={`${transaction.account}:${index}`}
380+
className="custom-class"
381+
data-testid="activity-list-item"
382+
onClick={() => toggleShowDetails(transaction)}
383+
icon={
384+
<BadgeWrapper
385+
anchorElementShape="circular"
386+
badge={
387+
<AvatarNetwork
388+
borderColor="background-default"
389+
borderWidth={1}
390+
className="activity-tx__network-badge"
391+
data-testid="activity-tx-network-badge"
392+
name={
393+
isSolanaAccount
394+
? MULTICHAIN_PROVIDER_CONFIGS[
395+
MultichainNetworks.SOLANA
396+
].nickname
397+
: MULTICHAIN_PROVIDER_CONFIGS[
398+
MultichainNetworks.BITCOIN
399+
].nickname
400+
}
401+
size="xs"
402+
src={
403+
isSolanaAccount
404+
? SOLANA_TOKEN_IMAGE_URL
405+
: BITCOIN_TOKEN_IMAGE_URL
406+
}
407+
/>
408+
}
409+
display="block"
410+
positionObj={{ right: -4, top: -4 }}
411+
>
412+
<TransactionIcon
413+
category={transaction.type}
414+
status={transaction.status}
415+
/>
416+
</BadgeWrapper>
417+
}
418+
rightContent={
419+
<>
420+
<Text
421+
className="activity-list-item__primary-currency"
422+
color="text-default"
423+
data-testid="transaction-list-item-primary-currency"
424+
ellipsis
425+
fontWeight="medium"
426+
textAlign="right"
427+
title="Primary Currency"
428+
variant="body-lg-medium"
403429
>
404-
<TransactionIcon
405-
category={transaction.type}
430+
{transaction.from?.[0]?.asset?.amount &&
431+
transaction.from[0]?.asset?.unit
432+
? `${transaction.from[0].asset.amount} ${transaction.from[0].asset.unit}`
433+
: ''}
434+
</Text>
435+
</>
436+
}
437+
title={
438+
transaction.isBridgeTx
439+
? t('bridge')
440+
: capitalize(transaction.type)
441+
}
442+
// eslint-disable-next-line react/jsx-no-duplicate-props
443+
subtitle={
444+
transaction.isBridgeTx && transaction.bridgeInfo ? (
445+
<>
446+
<TransactionStatusLabel
447+
date={formatTimestamp(transaction.timestamp)}
448+
error={{}}
406449
status={transaction.status}
450+
statusOnly
407451
/>
408-
</BadgeWrapper>
409-
}
410-
rightContent={
411-
<>
412452
<Text
413-
className="activity-list-item__primary-currency"
414-
color="text-default"
415-
data-testid="transaction-list-item-primary-currency"
416-
ellipsis
417-
fontWeight="medium"
418-
textAlign="right"
419-
title="Primary Currency"
420-
variant="body-lg-medium"
453+
variant={TextVariant.bodyMd}
454+
color={TextColor.textAlternative}
421455
>
422-
{transaction.from?.[0]?.asset?.amount &&
423-
transaction.from[0]?.asset?.unit
424-
? `${transaction.from[0].asset.amount} ${transaction.from[0].asset.unit}`
425-
: ''}
456+
{`${t('to')} ${
457+
transaction.bridgeInfo.destAsset?.symbol
458+
} ${t('on')} ${
459+
// Use the pre-computed chain name from our hook, or fall back to chain ID
460+
transaction.bridgeInfo.destChainName ||
461+
transaction.bridgeInfo.destChainId
462+
}`}
426463
</Text>
427464
</>
428-
}
429-
subtitle={
465+
) : (
430466
<TransactionStatusLabel
431467
date={formatTimestamp(transaction.timestamp)}
432468
error={{}}
433469
status={transaction.status}
434470
statusOnly
435471
/>
436-
}
437-
title={capitalize(transaction.type)}
438-
></ActivityListItem>
439-
))}
440-
</Fragment>
441-
),
442-
)}
472+
)
473+
}
474+
></ActivityListItem>
475+
))}
476+
</Fragment>
477+
))}
443478
<Box className="transaction-list__view-on-block-explorer">
444479
<Button
445480
display={Display.Flex}

ui/ducks/bridge-status/selectors.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export const selectBridgeStatusState = (state: BridgeStatusAppState) =>
1717
export const selectBridgeHistoryForAccount = createSelector(
1818
[getSelectedAddress, selectBridgeStatusState],
1919
(selectedAddress, bridgeStatusState) => {
20-
const { txHistory } = bridgeStatusState;
20+
// Handle the case when bridgeStatusState is undefined
21+
const { txHistory = {} } = bridgeStatusState || {};
2122

2223
return Object.keys(txHistory).reduce<Record<string, BridgeHistoryItem>>(
2324
(acc, txMetaId) => {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { useSelector } from 'react-redux';
2+
import { Transaction } from '@metamask/keyring-api';
3+
import { Numeric, NumericValue } from '../../../shared/modules/Numeric';
4+
import { NETWORK_TO_NAME_MAP } from '../../../shared/constants/network';
5+
import { MULTICHAIN_PROVIDER_CONFIGS } from '../../../shared/constants/multichain/networks';
6+
import { selectBridgeHistoryForAccount } from '../../ducks/bridge-status/selectors';
7+
8+
/**
9+
* Hook to map Solana bridge transactions with EVM destination info
10+
*
11+
* @param nonEvmTransactions - The non-EVM transactions to process
12+
* @returns Enhanced non-EVM transactions with bridging information
13+
*/
14+
export default function useSolanaBridgeTransactionMapping(
15+
nonEvmTransactions:
16+
| { transactions: Transaction[]; next: string | null; lastUpdated: number }
17+
| undefined,
18+
) {
19+
// Get bridge transactions from the bridge status controller
20+
const bridgeHistory = useSelector(selectBridgeHistoryForAccount);
21+
22+
/**
23+
* Gets a human-readable network name from a chain ID
24+
*
25+
* @param chainId - The chain ID to resolve (can be decimal or hex)
26+
* @returns The network name or the original chain ID if not found
27+
*/
28+
const getNetworkName = (chainId: NumericValue) => {
29+
// First check if it's in the MULTICHAIN_PROVIDER_CONFIGS (for non-EVM chains)
30+
// @ts-expect-error WIP: Need to fix type for indexing MULTICHAIN_PROVIDER_CONFIGS with NumericValue
31+
let networkName = MULTICHAIN_PROVIDER_CONFIGS[chainId]?.nickname;
32+
33+
// If not found and it might be an EVM chain ID, convert to hex and check NETWORK_TO_NAME_MAP
34+
if (!networkName && !isNaN(Number(chainId))) {
35+
try {
36+
// Convert decimal to hex with '0x' prefix
37+
const hexChainId = new Numeric(chainId, 10).toPrefixedHexString();
38+
// @ts-expect-error WIP: Need to fix type for indexing NETWORK_TO_NAME_MAP with string
39+
networkName = NETWORK_TO_NAME_MAP[hexChainId];
40+
} catch (e) {
41+
// If conversion fails, just use the original chain ID
42+
console.error('Error converting chain ID', e);
43+
}
44+
}
45+
46+
// Return the network name or the original chain ID if no mapping found
47+
return networkName || chainId;
48+
};
49+
50+
// Create a map of bridge transaction signatures for quick lookups
51+
const bridgeTxSignatures = {};
52+
if (bridgeHistory) {
53+
Object.values(bridgeHistory).forEach((bridgeTx) => {
54+
if (bridgeTx.status?.srcChain?.txHash) {
55+
// @ts-expect-error WIP: Need to add index signature to bridgeTxSignatures
56+
bridgeTxSignatures[bridgeTx.status.srcChain.txHash] = bridgeTx;
57+
}
58+
});
59+
}
60+
61+
// No transactions to process
62+
if (!nonEvmTransactions?.transactions?.length) {
63+
return nonEvmTransactions;
64+
}
65+
66+
// Create a modified copy with bridge info added
67+
const modifiedTransactions = nonEvmTransactions.transactions.map((tx) => {
68+
// The signature is in the id field for non-EVM transactions
69+
const txSignature = tx.id;
70+
71+
// Check if this transaction signature matches a bridge transaction
72+
if (
73+
txSignature &&
74+
// @ts-expect-error WIP: Need to add index signature to bridgeTxSignatures
75+
bridgeTxSignatures[txSignature]
76+
) {
77+
// @ts-expect-error WIP: Need to add index signature to bridgeTxSignatures
78+
const matchingBridgeTx = bridgeTxSignatures[txSignature];
79+
80+
// Return an enhanced version of the transaction with bridge info
81+
return {
82+
...tx,
83+
// Change the type to bridge
84+
type: 'bridge',
85+
// Add bridge-specific flags
86+
isBridgeTx: true,
87+
// Include destination chain details
88+
bridgeInfo: {
89+
destChainId: matchingBridgeTx.quote?.destChainId,
90+
destChainName: getNetworkName(matchingBridgeTx.quote?.destChainId),
91+
destAsset: matchingBridgeTx.quote?.destAsset,
92+
destTokenAmount: matchingBridgeTx.quote?.destTokenAmount,
93+
},
94+
};
95+
}
96+
97+
// Return the original transaction unchanged if not a bridge transaction
98+
return tx;
99+
});
100+
101+
// Return a modified copy of the original transactions with bridge info
102+
return {
103+
...nonEvmTransactions,
104+
transactions: modifiedTransactions,
105+
};
106+
}

0 commit comments

Comments
 (0)