Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: SOL-80 transaction details #29323

Merged
merged 47 commits into from
Feb 4, 2025
Merged
Show file tree
Hide file tree
Changes from 45 commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
0f5ffe2
chore: controllers and selector
zone-live Dec 9, 2024
2b86780
chore: ui display
zone-live Dec 9, 2024
38e51f3
chore: adds activity list and code fences
zone-live Dec 10, 2024
e7174a4
chore: adds tests and lint fixes
zone-live Dec 11, 2024
7a853df
Merge branch 'main' into SOL-46-adds-the-multichain-transactions-cont…
zone-live Dec 12, 2024
3a2c370
Merge branch 'main' into SOL-46-adds-the-multichain-transactions-cont…
zone-live Dec 12, 2024
b6efd42
chore: clean yarn lock file
zone-live Dec 12, 2024
1c86b76
chore: clean up
zone-live Dec 12, 2024
a6a92d6
Update LavaMoat policies
metamaskbot Dec 12, 2024
f79963d
chore: adds missing code fences
zone-live Dec 12, 2024
13a361c
chore: update with main
zone-live Dec 12, 2024
181c5ee
Merge branch 'SOL-46-adds-the-multichain-transactions-controller' of …
zone-live Dec 12, 2024
b1be837
chore: updates yarn lock
zone-live Dec 12, 2024
6637880
chore: remove unused locale
zone-live Dec 12, 2024
c8af9bc
chore: remove unnecessary assert
zone-live Dec 12, 2024
f567362
chore: update
zone-live Dec 12, 2024
a1f3d21
chore: remove unnecessary assert
zone-live Dec 12, 2024
c465217
chore: adds the ability to filter by mainnet txs only
zone-live Dec 12, 2024
f39282a
chore: lint fix
zone-live Dec 12, 2024
e6543dc
chore: lint undo
zone-live Dec 12, 2024
43a89fb
Merge branch 'main' into SOL-46-adds-the-multichain-transactions-cont…
zone-live Dec 12, 2024
0fb59a4
chore: adds tests
zone-live Dec 17, 2024
128e4a6
chore: lint fix
zone-live Dec 17, 2024
8bb949c
Merge branch 'main' into SOL-46-adds-the-multichain-transactions-cont…
zone-live Dec 18, 2024
08c396f
chore: reset to main the policies and yarn lock, fix prettier
zone-live Dec 18, 2024
6ad4665
chore: lint fix
zone-live Dec 18, 2024
915a0e9
Merge branch 'SOL-46-adds-the-multichain-transactions-controller' int…
zone-live Dec 18, 2024
fb3ab57
Merge branch 'main' into SOL-80-transaction-details
zone-live Dec 19, 2024
ad4a81c
Merge branch 'main' into SOL-80-transaction-details
zone-live Dec 20, 2024
9428075
Merge branch 'main' into SOL-80-transaction-details
zone-live Jan 6, 2025
81bc4a6
chore: fix conflicts and update with main
zone-live Jan 27, 2025
daaefd5
chore: clean up
zone-live Jan 27, 2025
0ccbaf2
Merge branch 'main' into SOL-80-transaction-details
zone-live Jan 27, 2025
5d75926
Update ui/components/app/multichain-transaction-details-modal/multich…
zone-live Jan 28, 2025
d6e121f
Update ui/components/app/multichain-transaction-details-modal/multich…
zone-live Jan 28, 2025
e5367df
chore: review points
zone-live Jan 29, 2025
c0116e2
chore: lint fix
zone-live Jan 29, 2025
28574ca
chore: clean up
zone-live Jan 29, 2025
d6d7a16
Merge branch 'main' into SOL-80-transaction-details
zone-live Jan 29, 2025
4be87de
chore: small update for solana timestaps
zone-live Jan 29, 2025
14dd87f
Merge branch 'main' into SOL-80-transaction-details
zone-live Jan 29, 2025
af5bb11
chore: test
zone-live Jan 29, 2025
3403374
chore: update privacy snapshot
zone-live Jan 29, 2025
5d0d442
chore: reset file
zone-live Jan 30, 2025
6996ee1
Merge branch 'main' into SOL-80-transaction-details
zone-live Jan 30, 2025
c29d881
chore: review points
zone-live Feb 4, 2025
684e9b6
Merge branch 'SOL-80-transaction-details' of github.com:MetaMask/meta…
zone-live Feb 4, 2025
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
6 changes: 5 additions & 1 deletion test/e2e/flask/solana/check-balance.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@ describe('Check balance', function (this: Suite) {
this.timeout(300000);
it('Just created Solana account shows 0 SOL when native token is enabled', async function () {
await withSolanaAccountSnap(
{ title: this.test?.fullTitle(), showNativeTokenAsMainBalance: true },
{
title: this.test?.fullTitle(),
solanaSupportEnabled: true,
showNativeTokenAsMainBalance: true,
},
async (driver) => {
await driver.refresh();
const homePage = new NonEvmHomepage(driver);
Expand Down
96 changes: 96 additions & 0 deletions ui/components/app/multichain-transaction-details-modal/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { DateTime } from 'luxon';
import {
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP,
MultichainNetworks,
} from '../../../../shared/constants/multichain/networks';
import { formatDateWithYearContext } from '../../../helpers/utils/util';

/**
* Creates a transaction URL for block explorer based on network type
* Different networks have different URL patterns:
* Bitcoin Mainnet: https://blockstream.info/tx/{txId}
* Bitcoin Testnet: https://blockstream.info/testnet/tx/{txId}
* Solana Mainnet: https://explorer.solana.com/tx/{txId}
* Solana Devnet: https://explorer.solana.com/tx/{txId}?cluster=devnet
*
* @param txId - Transaction ID
* @param chainId - Network chain ID
* @returns Full URL to transaction in block explorer, or empty string if no explorer URL
*/
export const getTransactionUrl = (txId: string, chainId: string): string => {
Copy link
Contributor

Choose a reason for hiding this comment

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

As discussed internally, I think this kind of logic will not scale well in the future.

I have prepared another PR that will use format-strings instead to build block explorer URLs:

I'll make the changes to support transactions once we have merged this PR!

const explorerBaseUrl =
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[chainId as MultichainNetworks];
if (!explorerBaseUrl) {
return '';
}

// Change address URL to transaction URL for Bitcoin
if (chainId.startsWith('bip122:')) {
return `${explorerBaseUrl.replace('/address', '/tx')}/${txId}`;
}

const baseUrl = explorerBaseUrl.split('?')[0];
if (chainId === MultichainNetworks.SOLANA) {
return `${baseUrl}tx/${txId}`;
}
if (chainId === MultichainNetworks.SOLANA_DEVNET) {
return `${baseUrl}tx/${txId}?cluster=devnet`;
}

return '';
};

/**
* Creates an address URL for block explorer based on network type
* Different networks have different URL patterns:
* Bitcoin Mainnet: https://blockstream.info/address/{address}
* Bitcoin Testnet: https://blockstream.info/testnet/address/{address}
* Solana Mainnet: https://explorer.solana.com/address/{address}
* Solana Devnet: https://explorer.solana.com/address/{address}?cluster=devnet
*
* @param address - Wallet address
* @param chainId - Network chain ID
* @returns Full URL to address in block explorer, or empty string if no explorer URL
*/
export const getAddressUrl = (address: string, chainId: string): string => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Same here.

const explorerBaseUrl =
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP[chainId as MultichainNetworks];

if (!explorerBaseUrl) {
return '';
}

const baseUrl = explorerBaseUrl.split('?')[0];
if (chainId === MultichainNetworks.SOLANA) {
return `${baseUrl}address/${address}`;
}
if (chainId === MultichainNetworks.SOLANA_DEVNET) {
return `${baseUrl}address/${address}?cluster=devnet`;
}

// Bitcoin networks already have the correct address URL format
return `${explorerBaseUrl}/${address}`;
};

/**
* Formats a timestamp into a localized date and time string
* Example outputs: "Mar 15, 2024, 14:30" or "Dec 25, 2023, 09:45"
*
* @param timestamp - Unix timestamp in milliseconds
* @returns Formatted date and time string, or empty string if timestamp is null
*/
export const formatTimestamp = (timestamp: number | null) => {
if (!timestamp) {
return '';
}

// It's typical for Solana timestamps to use seconds, while JS Dates and most EVM chains use milliseconds.
// Hence we needed to use the conversion `timestamp < 1e12 ? timestamp * 1000 : timestamp` for it to work.
const timestampMs = timestamp < 1e12 ? timestamp * 1000 : timestamp;
Copy link
Contributor

Choose a reason for hiding this comment

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

Since timestamps must be UNIX timestamp, they should be using "seconds" already, so I think we should just use timestamp * 1000.

However, since only the Solana Snap implements this for now, I'll keep it as is and we will revisit this later once we have stabilized everything!


const dateTime = DateTime.fromMillis(timestampMs);
const date = formatDateWithYearContext(timestampMs, 'MMM d, y', 'MMM d');
const time = dateTime.toFormat('HH:mm');

return `${date}, ${time}`;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';

export default {
title: 'Components/App/MultichainTransactionDetailsModal',
component: MultichainTransactionDetailsModal,
};

const mockTransaction = {
type: 'Send BTC',
status: 'Confirmed',
timestamp: new Date('Sep 30 2023 12:56').getTime(),
id: 'b93ea2cb4eed0f9e13284ed8860bcfc45de2488bb6a8b0b2a843c4b2fbce40f3',
from: [{
address: "bc1p7atgm33ak04ntsq9366mvym42ecrk4y34ssysc99340a39eq9arq0pu9uj",
asset: {
amount: '1.2',
unit: 'BTC',
}
}],
to: [{
address: "bc1p3t7744qewy262ym5afgeuqlwswtpfe22y7c4lwv0a7972p2k73msee7rr3",
asset: {
amount: '1.2',
unit: 'BTC',
}
}],
fees: [{
type: 'base',
asset: {
amount: '1.0001',
unit: 'BTC',
}
}]
};

export const Default = {
args: {
transaction: mockTransaction,
onClose: () => console.log('Modal closed'),
addressLink: 'https://explorer.bitcoin.com/btc/tx/3302...90c1',
multichainNetwork: {
nickname: 'Bitcoin',
isEvmNetwork: false,
chainId: 'bip122:000000000019d6689c085ae165831e93',
network: {
chainId: 'bip122:000000000019d6689c085ae165831e93',
ticker: 'BTC',
},
},
},
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import React from 'react';
import { CaipChainId } from '@metamask/utils';
import { CaipAssetType, TransactionStatus } from '@metamask/keyring-api';
import { screen, fireEvent } from '@testing-library/react';
import { shortenAddress } from '../../../helpers/utils/util';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { MultichainNetworks } from '../../../../shared/constants/multichain/networks';
import { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal';
import { getTransactionUrl } from './helpers';

jest.mock('../../../hooks/useI18nContext', () => ({
useI18nContext: jest.fn(),
}));

const mockTransaction = {
type: 'send' as const,
status: TransactionStatus.Confirmed as TransactionStatus,
timestamp: new Date('2023-09-30T12:56:00').getTime(),
id: 'b93ea2cb4eed0f9e13284ed8860bcfc45de2488bb6a8b0b2a843c4b2fbce40f3',
chain: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
account: 'test-account-id',
events: [],
from: [
{
address: 'bc1ql49ydapnjafl5t2cp9zqpjwe6pdgmxy98859v2',
asset: {
fungible: true as const,
type: 'native' as CaipAssetType,
amount: '1.2',
unit: 'BTC',
},
},
],
to: [
{
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
asset: {
fungible: true as const,
type: 'native' as CaipAssetType,
amount: '1.2',
unit: 'BTC',
},
},
],
fees: [
{
type: 'base' as const,
asset: {
fungible: true as const,
type: 'native' as CaipAssetType,
amount: '1.0001',
unit: 'BTC',
},
},
],
};

const mockProps = {
transaction: mockTransaction,
onClose: jest.fn(),
addressLink: 'https://explorer.bitcoin.com/btc/tx/3302...90c1',
multichainNetwork: {
nickname: 'Bitcoin',
isEvmNetwork: false,
chainId: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
network: {
type: 'bitcoin',
chainId: 'bip122:000000000019d6689c085ae165831e93' as CaipChainId,
ticker: 'BTC',
nickname: 'Bitcoin',
isAddressCompatible: (_address: string) => true,
rpcPrefs: {
blockExplorerUrl: 'https://explorer.bitcoin.com',
},
},
},
};

describe('MultichainTransactionDetailsModal', () => {
const mockTrackEvent = jest.fn();
const useI18nContextMock = useI18nContext as jest.Mock;

beforeEach(() => {
useI18nContextMock.mockReturnValue((key: string) => key);
});

afterEach(() => {
jest.clearAllMocks();
});

const renderComponent = (props = mockProps) => {
return renderWithProvider(
<MetaMetricsContext.Provider value={mockTrackEvent}>
<MultichainTransactionDetailsModal {...props} />
</MetaMetricsContext.Provider>,
);
};

it('renders the modal with transaction details', () => {
renderComponent();

expect(screen.getByText('Send')).toBeInTheDocument();
expect(screen.getByText('Confirmed')).toBeInTheDocument();
expect(screen.getByTestId('transaction-amount')).toHaveTextContent(
'1.2 BTC',
);
});

it('displays the correct transaction status with appropriate color', () => {
renderComponent();
const statusElement = screen.getByText('Confirmed');
expect(statusElement).toHaveClass('mm-box--color-success-default');
});

it('shows transaction ID in shortened format', () => {
renderComponent();
const txId = mockTransaction.id;
const shortenedTxId = screen.getByText(shortenAddress(txId));
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: But this should have been shortenTransactionId here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Will add in an other PR

expect(shortenedTxId).toBeInTheDocument();
});

it('displays network fee when present', () => {
renderComponent();
expect(screen.getByText('1.0001 BTC')).toBeInTheDocument();
});

it('calls onClose when close button is clicked', () => {
renderComponent();
const closeButton = screen.getByRole('button', { name: /close/iu });
fireEvent.click(closeButton);
expect(mockProps.onClose).toHaveBeenCalled();
});

it('renders the view details button with correct link', () => {
renderComponent();
const viewDetailsButton = screen.getByText('viewDetails');
expect(viewDetailsButton).toBeInTheDocument();
fireEvent.click(viewDetailsButton);
expect(mockTrackEvent).toHaveBeenCalled();
});

// @ts-expect-error This is missing from the Mocha type definitions
it.each(['confirmed', 'pending', 'failed'] as const)(
'handles different transaction status: %s',
(status: string) => {
const propsWithStatus = {
...mockProps,
transaction: {
...mockTransaction,
status: status as TransactionStatus,
},
};
renderComponent(propsWithStatus);
expect(
screen.getByText(status.charAt(0).toUpperCase() + status.slice(1)),
).toBeInTheDocument();
},
);

it('returns correct Bitcoin mainnet transaction URL', () => {
const txId =
'447755f24ab40f469309f357cfdd9e375e9569b2cf68aaeba2ebcc232eac9568';
const chainId = MultichainNetworks.BITCOIN;

expect(getTransactionUrl(txId, chainId)).toBe(
`https://blockstream.info/tx/${txId}`,
);
});

it('returns correct Bitcoin testnet transaction URL', () => {
const txId =
'447755f24ab40f469309f357cfdd9e375e9569b2cf68aaeba2ebcc232eac9568';
const chainId = MultichainNetworks.BITCOIN_TESTNET;

expect(getTransactionUrl(txId, chainId)).toBe(
`https://blockstream.info/testnet/tx/${txId}`,
);
});

it('returns correct Solana mainnet transaction URL', () => {
const txId =
'5Y64J6gUNd67hM63Aeks3qVLGWRM3A52PFFjqKSPTVDdAZFbaPDHHLTFCs3ioeFcAAXFmqcUftZeLJVZCzqovAJ4';
const chainId = MultichainNetworks.SOLANA;

expect(getTransactionUrl(txId, chainId)).toBe(
`https://explorer.solana.com/tx/${txId}`,
);
});

it('returns correct Solana devnet transaction URL', () => {
const txId =
'5Y64J6gUNd67hM63Aeks3qVLGWRM3A52PFFjqKSPTVDdAZFbaPDHHLTFCs3ioeFcAAXFmqcUftZeLJVZCzqovAJ4';
const chainId = MultichainNetworks.SOLANA_DEVNET;

expect(getTransactionUrl(txId, chainId)).toBe(
`https://explorer.solana.com/tx/${txId}?cluster=devnet`,
);
});
});
Loading
Loading