Skip to content

Commit

Permalink
feat: SOL-80 transaction details (#29323)
Browse files Browse the repository at this point in the history
<!--
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
3 people authored Feb 4, 2025
1 parent 9bd0243 commit 86334eb
Show file tree
Hide file tree
Showing 7 changed files with 844 additions and 108 deletions.
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
110 changes: 110 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,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);
}
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));
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

0 comments on commit 86334eb

Please sign in to comment.