-
Notifications
You must be signed in to change notification settings - Fork 5k
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
Changes from 45 commits
0f5ffe2
2b86780
38e51f3
e7174a4
7a853df
3a2c370
b6efd42
1c86b76
a6a92d6
f79963d
13a361c
181c5ee
b1be837
6637880
c8af9bc
f567362
a1f3d21
c465217
f39282a
e6543dc
43a89fb
0fb59a4
128e4a6
8bb949c
08c396f
6ad4665
915a0e9
fb3ab57
ad4a81c
9428075
81bc4a6
daaefd5
0ccbaf2
5d75926
d6e121f
e5367df
c0116e2
28574ca
d6d7a16
4be87de
14dd87f
af5bb11
3403374
5d0d442
6996ee1
c29d881
684e9b6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 => { | ||
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 => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: But this should have been There was a problem hiding this comment. Choose a reason for hiding this commentThe 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`, | ||
); | ||
}); | ||
}); |
There was a problem hiding this comment.
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!