-
Notifications
You must be signed in to change notification settings - Fork 5k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: SOL-80 transaction details (#29323)
<!-- 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** This PR adds the new transaction details modal for the non-evm networks, BTC and SOL. ![Screenshot 2024-12-18 at 14 42 18](https://github.com/user-attachments/assets/95413caf-fb72-4811-98c4-f0d4416fa816) https://github.com/user-attachments/assets/d19da5a3-46af-4d8c-a220-59bbd663624a ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/SOL-80 ## **Manual testing steps** Testing this is a bit extensive, but if you still want to give it a go these are the steps: 1. Checkout this branch and run `yarn` 2. Update the file `shared/lib/accounts/solana-wallet-snap.ts` with: `export const SOLANA_WALLET_SNAP_ID: SnapId = 'local:http://localhost:8080' as SnapId;` 3. Update the filtering code in MultichainTransactionsController under node modules to return transactions for devnet, currently only returns for mainnet. It's under `node_modules/@metamask/multichain-transactions-controller/dist/MultichainTransactionsController.mjs` and `node_modules/@metamask/multichain-transactions-controller/dist/MultichainTransactionsController.cjs` with: ``` MultichainNetwork.SolanaDevnet instead of MultichainNetwork.Solana ``` 4. Run the extension with `yarn start:flask` 5. Run the Snap: https://github.com/MetaMask/snap-solana-wallet - Clone it - Run `yarn` - Run `yarn start` 6. Go to http://localhost:3000/ 7. Install the Snap 8. In the extension, go to the Settings > Experimental > Enable Solana account 9. Create a Solana account from the account-list menu 10. Fund the new Solana account with some SOL, use a faucet like https://faucet.solana.com/ 11. the initial Tx with funds from the faucet will display in the activity tab 12. Click in it and you will see the Tx details modal 13. Thats it! 🎉 ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** Didn't exist. ### **After** ![Screenshot 2024-12-18 at 14 42 18](https://github.com/user-attachments/assets/95413caf-fb72-4811-98c4-f0d4416fa816) ## **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: MetaMask Bot <[email protected]> Co-authored-by: Charly Chevalier <[email protected]>
- Loading branch information
1 parent
9bd0243
commit 86334eb
Showing
7 changed files
with
844 additions
and
108 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
110 changes: 110 additions & 0 deletions
110
ui/components/app/multichain-transaction-details-modal/helpers.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,110 @@ | ||
import { DateTime } from 'luxon'; | ||
import { | ||
MULTICHAIN_NETWORK_BLOCK_EXPLORER_URL_MAP, | ||
MultichainNetworks, | ||
} from '../../../../shared/constants/multichain/networks'; | ||
import { | ||
formatDateWithYearContext, | ||
shortenAddress, | ||
} 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 => { | ||
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; | ||
|
||
const dateTime = DateTime.fromMillis(timestampMs); | ||
const date = formatDateWithYearContext(timestampMs, 'MMM d, y', 'MMM d'); | ||
const time = dateTime.toFormat('HH:mm'); | ||
|
||
return `${date}, ${time}`; | ||
}; | ||
|
||
/** | ||
* Formats a shorten version of a transaction ID. | ||
* | ||
* @param txId - Transaction ID. | ||
* @returns Formatted transaction ID. | ||
*/ | ||
export function shortenTransactionId(txId: string) { | ||
// For transactions we use a similar output for now, but shortenTransactionId will be added later. | ||
return shortenAddress(txId); | ||
} |
1 change: 1 addition & 0 deletions
1
ui/components/app/multichain-transaction-details-modal/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { MultichainTransactionDetailsModal } from './multichain-transaction-details-modal'; |
51 changes: 51 additions & 0 deletions
51
...app/multichain-transaction-details-modal/multichain-transaction-details-modal.stories.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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', | ||
}, | ||
}, | ||
}, | ||
}; |
201 changes: 201 additions & 0 deletions
201
...ts/app/multichain-transaction-details-modal/multichain-transaction-details-modal.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
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`, | ||
); | ||
}); | ||
}); |
Oops, something went wrong.