Skip to content

Commit 9d483dd

Browse files
authored
fix: Checksum EVM addresses in the address list (#38539)
## **Description** 1. What is the reason for the change? The EVM address on the address list page was not checksummed while it was on on the home page. This is important for users so that their addresses match what they see in the ecosystem. 2. What is the improvement/solution? Call the following formatting function. ```ts /** * Normalize an address to a "safer" representation. The address might be returned as-is, if * there's no normalizer available. * * @param address - An address to normalize. * @returns The "safer" normalized address. */ export function normalizeSafeAddress(address: string): string { // NOTE: We assume that the overhead over checking the address format // at runtime is small return isEthAddress(address) ? toChecksumHexAddress(address) : address; } ``` [![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/38539?quickstart=1) ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Fixed bug where the EVM addresses were not checksummed ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/MUL-1325 ## **Manual testing steps** 1. Create/impor a wallet 2. hover address list via the network icons below the account name 3. notice the address for ETH 4. click view all 5. you will be taken to the address list 6. verify that the addresses for the EVm networks are checksummed 7. verify that copying the address results in a checksummed address 8. verify that the qr code is checksummed 9. verify that the non evm addresses are NOT checksummed ## **Screenshots/Recordings** ### **Before** <img width="405" height="625" alt="Screenshot 2025-12-03 at 11 54 18 PM" src="https://github.com/user-attachments/assets/815cf104-55d5-4922-83cc-a0ed521874a6" /> <img width="413" height="624" alt="Screenshot 2025-12-03 at 11 54 24 PM" src="https://github.com/user-attachments/assets/d597d6a3-a3dc-4968-8e7d-8b18dc9c6db4" /> ### **After** <img width="425" height="633" alt="Screenshot 2025-12-03 at 8 10 56 PM" src="https://github.com/user-attachments/assets/2f5dccf2-3c78-41c2-a9aa-e0ee150e7516" /> <img width="414" height="621" alt="Screenshot 2025-12-03 at 8 10 48 PM" src="https://github.com/user-attachments/assets/152123c8-3691-4add-8616-1b8e7cdef993" /> <img width="447" height="630" alt="Screenshot 2025-12-03 at 8 11 24 PM" src="https://github.com/user-attachments/assets/b213f18d-315b-418d-aa05-03c0c392ccd6" /> https://github.com/user-attachments/assets/9cef5a93-4e0f-4de9-880a-744eaaa3465a ## **Pre-merge author checklist** - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Checksums EVM addresses for display, copy, and search in the multichain address list while preserving non‑EVM formats, with accompanying tests. > > - **UI** > - Use `normalizeSafeAddress` to provide a normalized `address` for `MultichainAddressRow` and for copy actions. > - Search now matches against `normalizedAddress` in addition to network name. > - Memoize `normalizedAddress` per item and render list using the normalized values. > - Generalize `sortByPriorityNetworks` with a generic type. > - **Tests** > - Add tests verifying EVM addresses are checksummed for display/copy and searchable by checksummed form. > - Confirm non‑EVM addresses (e.g., Bitcoin) remain unchanged. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 221ffd6. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent a729e80 commit 9d483dd

File tree

2 files changed

+168
-13
lines changed

2 files changed

+168
-13
lines changed

ui/components/multichain-accounts/multichain-address-rows-list/multichain-address-rows-list.test.tsx

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -578,4 +578,152 @@ describe('MultichainAddressRowsList', () => {
578578
}
579579
});
580580
});
581+
582+
describe('Address formatting', () => {
583+
it('formats addresses to checksum format for display and copy', async () => {
584+
// Create a state with a lowercase EVM address that needs formatting
585+
const lowercaseAddress = '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed';
586+
587+
const customState = createMockState();
588+
const customAccountId = 'test-account-lowercase';
589+
590+
// Add an account with a lowercase address
591+
customState.metamask.internalAccounts.accounts[customAccountId] = {
592+
id: customAccountId,
593+
address: lowercaseAddress,
594+
metadata: {
595+
name: 'Test Account',
596+
importTime: Date.now(),
597+
keyring: { type: 'HD Key Tree' },
598+
},
599+
options: {},
600+
methods: [],
601+
type: 'eip155:eoa',
602+
scopes: ['eip155:0'],
603+
};
604+
605+
const customGroupId = `${WALLET_ID_MOCK}/test-group`;
606+
(
607+
customState.metamask.accountTree.wallets[WALLET_ID_MOCK]
608+
.groups as Record<string, unknown>
609+
)[customGroupId] = {
610+
type: 'multichain-account',
611+
id: customGroupId,
612+
metadata: {},
613+
accounts: [customAccountId],
614+
};
615+
616+
const store = mockStore(customState);
617+
render(
618+
<Provider store={store}>
619+
<MultichainAddressRowsList
620+
groupId={customGroupId as AccountGroupId}
621+
onQrClick={jest.fn()}
622+
/>
623+
</Provider>,
624+
);
625+
626+
// Find the address text element
627+
const addressElements = screen.getAllByTestId(
628+
'multichain-address-row-address',
629+
);
630+
631+
// The displayed address should be shortened, but when we copy, it should be the full checksum
632+
// We can't directly test the full address since it's shortened, but we can test the copy functionality
633+
const copyButton = screen.getAllByTestId(
634+
'multichain-address-row-copy-button',
635+
)[0];
636+
637+
// Mock clipboard
638+
Object.assign(navigator, {
639+
clipboard: {
640+
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
641+
},
642+
});
643+
644+
fireEvent.click(copyButton);
645+
646+
// The useCopyToClipboard hook should have been called with the checksummed address
647+
// Note: We can verify this indirectly by checking that the component renders without errors
648+
// and the copy success state is shown
649+
expect(addressElements[0]).toHaveTextContent(/copied|0x5a/iu);
650+
});
651+
652+
it('searches using formatted addresses', () => {
653+
// Create a state with a lowercase EVM address
654+
const lowercaseAddress = '0x5aaeb6053f3e94c9b9a09f33669435e7ef1beaed';
655+
656+
const customState = createMockState();
657+
const customAccountId = 'test-account-search';
658+
659+
customState.metamask.internalAccounts.accounts[customAccountId] = {
660+
id: customAccountId,
661+
address: lowercaseAddress,
662+
metadata: {
663+
name: 'Search Test Account',
664+
importTime: Date.now(),
665+
keyring: { type: 'HD Key Tree' },
666+
},
667+
options: {},
668+
methods: [],
669+
type: 'eip155:eoa',
670+
scopes: ['eip155:0'],
671+
};
672+
673+
const customGroupId = `${WALLET_ID_MOCK}/search-group`;
674+
(
675+
customState.metamask.accountTree.wallets[WALLET_ID_MOCK]
676+
.groups as Record<string, unknown>
677+
)[customGroupId] = {
678+
type: 'multichain-account',
679+
id: customGroupId,
680+
metadata: {},
681+
accounts: [customAccountId],
682+
};
683+
684+
const store = mockStore(customState);
685+
render(
686+
<Provider store={store}>
687+
<MultichainAddressRowsList
688+
groupId={customGroupId as AccountGroupId}
689+
onQrClick={jest.fn()}
690+
/>
691+
</Provider>,
692+
);
693+
694+
const searchInput = screen
695+
.getByTestId('multichain-address-rows-list-search')
696+
.querySelector('input') as HTMLInputElement;
697+
698+
// Search for the checksummed version - should find the address even though it was stored lowercase
699+
fireEvent.change(searchInput, { target: { value: '0x5aAeb' } });
700+
701+
const addressRows = screen.queryAllByTestId('multichain-address-row');
702+
expect(addressRows.length).toBeGreaterThan(0);
703+
});
704+
705+
it('preserves non-EVM addresses as-is', () => {
706+
renderComponent();
707+
708+
// Find Bitcoin and Solana addresses
709+
const addressElements = screen.getAllByTestId(
710+
'multichain-address-row-address',
711+
);
712+
const networkNames = screen.getAllByTestId(
713+
'multichain-address-row-network-name',
714+
);
715+
716+
// Find the Bitcoin row
717+
const bitcoinIndex = Array.from(networkNames).findIndex(
718+
(el) => el.textContent === 'Bitcoin',
719+
);
720+
721+
if (bitcoinIndex !== -1) {
722+
// Bitcoin address should be preserved as-is (not checksummed)
723+
const bitcoinAddressElement = addressElements[bitcoinIndex];
724+
// The element should contain part of the Bitcoin address (shortened)
725+
expect(bitcoinAddressElement.textContent).toMatch(/bc1q/u);
726+
}
727+
});
728+
});
581729
});

ui/components/multichain-accounts/multichain-address-rows-list/multichain-address-rows-list.tsx

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
import { useCopyToClipboard } from '../../../hooks/useCopyToClipboard';
2424
import { MultichainAddressRow } from '../multichain-address-row/multichain-address-row';
2525
import { getInternalAccountListSpreadByScopesByGroupId } from '../../../selectors/multichain-accounts/account-tree';
26+
// eslint-disable-next-line import/no-restricted-paths
27+
import { normalizeSafeAddress } from '../../../../app/scripts/lib/multichain/address';
2628

2729
// Priority networks that should appear first (using CAIP chain IDs)
2830
const PRIORITY_CHAIN_IDS: CaipChainId[] = [
@@ -62,9 +64,9 @@ export const MultichainAddressRowsList = ({
6264
);
6365

6466
const sortByPriorityNetworks = useCallback(
65-
(items: typeof getAccountsSpreadByNetworkByGroupId) => {
66-
const priorityItems: typeof items = [];
67-
const otherItems: typeof items = [];
67+
<ItemType extends { scope: CaipChainId }>(items: ItemType[]) => {
68+
const priorityItems: ItemType[] = [];
69+
const otherItems: ItemType[] = [];
6870

6971
items.forEach((item) => {
7072
const priorityIndex = PRIORITY_CHAIN_IDS.findIndex(
@@ -85,27 +87,31 @@ export const MultichainAddressRowsList = ({
8587
[],
8688
);
8789

90+
// Normalize addresses once for all items for performance
91+
const itemsWithNormalizedAddresses = useMemo(() => {
92+
return getAccountsSpreadByNetworkByGroupId.map((item) => ({
93+
...item,
94+
normalizedAddress: normalizeSafeAddress(item.account.address),
95+
}));
96+
}, [getAccountsSpreadByNetworkByGroupId]);
97+
8898
const filteredItems = useMemo(() => {
89-
let items = getAccountsSpreadByNetworkByGroupId;
99+
let items = itemsWithNormalizedAddresses;
90100

91101
// Apply search filter if there's a search pattern
92102
if (searchPattern.trim()) {
93103
const pattern = searchPattern.toLowerCase();
94-
items = items.filter(({ networkName, account }) => {
104+
items = items.filter(({ networkName, normalizedAddress }) => {
95105
return (
96106
networkName.toLowerCase().includes(pattern) ||
97-
account.address.toLowerCase().includes(pattern)
107+
normalizedAddress.toLowerCase().includes(pattern)
98108
);
99109
});
100110
}
101111

102112
// Sort by priority networks
103113
return sortByPriorityNetworks(items);
104-
}, [
105-
getAccountsSpreadByNetworkByGroupId,
106-
searchPattern,
107-
sortByPriorityNetworks,
108-
]);
114+
}, [itemsWithNormalizedAddresses, searchPattern, sortByPriorityNetworks]);
109115

110116
const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
111117
setSearchPattern(event.target.value);
@@ -121,19 +127,20 @@ export const MultichainAddressRowsList = ({
121127
scope: CaipChainId;
122128
account: InternalAccount;
123129
networkName: string;
130+
normalizedAddress: string;
124131
},
125132
index: number,
126133
): React.JSX.Element => {
127134
const handleCopyClick = () => {
128-
handleCopy(item.account.address);
135+
handleCopy(item.normalizedAddress);
129136
};
130137

131138
return (
132139
<MultichainAddressRow
133140
key={`${item.account.address}-${item.scope}-${index}`}
134141
chainId={item.scope}
135142
networkName={item.networkName}
136-
address={item.account.address}
143+
address={item.normalizedAddress}
137144
copyActionParams={{
138145
message: t('multichainAccountAddressCopied'),
139146
callback: handleCopyClick,

0 commit comments

Comments
 (0)