diff --git a/ui/components/app/metamask-template-renderer/safe-component-list.js b/ui/components/app/metamask-template-renderer/safe-component-list.js index 40932c2fbb4a..5756c5cbed2b 100644 --- a/ui/components/app/metamask-template-renderer/safe-component-list.js +++ b/ui/components/app/metamask-template-renderer/safe-component-list.js @@ -42,6 +42,7 @@ import { SnapUIRadioGroup } from '../snaps/snap-ui-radio-group'; import { SnapUICheckbox } from '../snaps/snap-ui-checkbox'; import { SnapUITooltip } from '../snaps/snap-ui-tooltip'; import { SnapUICard } from '../snaps/snap-ui-card'; +import { SnapUIAddress } from '../snaps/snap-ui-address'; import { SnapUISelector } from '../snaps/snap-ui-selector'; import { SnapFooterButton } from '../snaps/snap-footer-button'; ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) @@ -104,6 +105,7 @@ export const safeComponentList = { SnapUITooltip, SnapUICard, SnapUISelector, + SnapUIAddress, SnapFooterButton, FormTextField, ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) diff --git a/ui/components/app/snaps/snap-ui-address/__snapshots__/snap-ui-address.test.tsx.snap b/ui/components/app/snaps/snap-ui-address/__snapshots__/snap-ui-address.test.tsx.snap new file mode 100644 index 000000000000..d29236409dbc --- /dev/null +++ b/ui/components/app/snaps/snap-ui-address/__snapshots__/snap-ui-address.test.tsx.snap @@ -0,0 +1,498 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SnapUIAddress renders Bitcoin address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 128Lkh3...Mp8p6 +

+
+
+`; + +exports[`SnapUIAddress renders Bitcoin address with blockie 1`] = ` +
+
+ +

+ 128Lkh3...Mp8p6 +

+
+
+`; + +exports[`SnapUIAddress renders Cosmos address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ cosmos1...6hdc0 +

+
+
+`; + +exports[`SnapUIAddress renders Cosmos address with blockie 1`] = ` +
+
+ +

+ cosmos1...6hdc0 +

+
+
+`; + +exports[`SnapUIAddress renders Ethereum address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 0xab16a...Bfcdb +

+
+
+`; + +exports[`SnapUIAddress renders Ethereum address with blockie 1`] = ` +
+
+ +

+ 0xab16a...Bfcdb +

+
+
+`; + +exports[`SnapUIAddress renders Hedera address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 0.0.123...zbhlt +

+
+
+`; + +exports[`SnapUIAddress renders Hedera address with blockie 1`] = ` +
+
+ +

+ 0.0.123...zbhlt +

+
+
+`; + +exports[`SnapUIAddress renders Polkadot address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 5hmuyxw...egmfy +

+
+
+`; + +exports[`SnapUIAddress renders Polkadot address with blockie 1`] = ` +
+
+ +

+ 5hmuyxw...egmfy +

+
+
+`; + +exports[`SnapUIAddress renders Starknet address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 0x02dd1...0ab57 +

+
+
+`; + +exports[`SnapUIAddress renders Starknet address with blockie 1`] = ` +
+
+ +

+ 0x02dd1...0ab57 +

+
+
+`; + +exports[`SnapUIAddress renders legacy Ethereum address 1`] = ` +
+
+
+
+ + + + + +
+
+

+ 0xab16a...Bfcdb +

+
+
+`; diff --git a/ui/components/app/snaps/snap-ui-address/index.ts b/ui/components/app/snaps/snap-ui-address/index.ts new file mode 100644 index 000000000000..81652b9432d3 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-address/index.ts @@ -0,0 +1 @@ +export * from './snap-ui-address'; diff --git a/ui/components/app/snaps/snap-ui-address/snap-ui-address.stories.tsx b/ui/components/app/snaps/snap-ui-address/snap-ui-address.stories.tsx new file mode 100644 index 000000000000..1dbb08b7938b --- /dev/null +++ b/ui/components/app/snaps/snap-ui-address/snap-ui-address.stories.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { SnapUIAddress } from './snap-ui-address'; + +export default { + title: 'Components/App/Snaps/SnapUIAddress', + component: SnapUIAddress, + argTypes: {}, +}; + +export const EthereumStory = (args) => ; + +EthereumStory.storyName = 'Ethereum'; + +EthereumStory.args = { + address: 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', +}; + +export const BitcoinStory = (args) => ; + +BitcoinStory.storyName = 'Bitcoin'; + +BitcoinStory.args = { + address: + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', +}; + +export const CosmosStory = (args) => ; + +CosmosStory.storyName = 'Cosmos'; + +CosmosStory.args = { + address: 'cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0', +}; + +export const PolkadotStory = (args) => ; + +PolkadotStory.storyName = 'Polkadot'; + +PolkadotStory.args = { + address: + 'polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy', +}; + +export const StarknetStory = (args) => ; + +StarknetStory.storyName = 'Starknet'; + +StarknetStory.args = { + address: + 'starknet:SN_GOERLI:0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57', +}; + +export const HederaStory = (args) => ; + +HederaStory.storyName = 'Hedera'; + +HederaStory.args = { + address: 'hedera:mainnet:0.0.1234567890-zbhlt', +}; + +export const All = () => ( + <> + + + + + + + +); diff --git a/ui/components/app/snaps/snap-ui-address/snap-ui-address.test.tsx b/ui/components/app/snaps/snap-ui-address/snap-ui-address.test.tsx new file mode 100644 index 000000000000..e7c18a70a7c5 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-address/snap-ui-address.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; + +import mockState from '../../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../../test/lib/render-helpers'; +import { SnapUIAddress } from './snap-ui-address'; + +const mockStore = configureMockStore([])(mockState); +const mockStoreWithBlockies = configureMockStore([])({ + ...mockState, + metamask: { + ...mockState.metamask, + useBlockie: true, + }, +}); + +describe('SnapUIAddress', () => { + it('renders legacy Ethereum address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Ethereum address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Ethereum address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Bitcoin address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Bitcoin address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Cosmos address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Cosmos address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Polkadot address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Polkadot address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Starknet address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Starknet address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Hedera address', () => { + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders Hedera address with blockie', () => { + const { container } = renderWithProvider( + , + mockStoreWithBlockies, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx b/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx new file mode 100644 index 000000000000..669f7dd30799 --- /dev/null +++ b/ui/components/app/snaps/snap-ui-address/snap-ui-address.tsx @@ -0,0 +1,67 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { + CaipAccountId, + isHexString, + parseCaipAccountId, +} from '@metamask/utils'; +import { Box, Text } from '../../../component-library'; +import { + AlignItems, + Display, + TextColor, +} from '../../../../helpers/constants/design-system'; +import BlockieIdenticon from '../../../ui/identicon/blockieIdenticon'; +import Jazzicon from '../../../ui/jazzicon'; +import { getUseBlockie } from '../../../../selectors'; +import { shortenAddress } from '../../../../helpers/utils/util'; +import { toChecksumHexAddress } from '../../../../../shared/modules/hexstring-utils'; + +export type SnapUIAddressProps = { + // The address must be a CAIP-10 string. + address: string; + diameter?: number; +}; + +export const SnapUIAddress: React.FunctionComponent = ({ + address, + diameter = 32, +}) => { + const parsed = useMemo(() => { + if (isHexString(address)) { + // For legacy address inputs we assume them to be Ethereum addresses. + // NOTE: This means the chain ID is not gonna be reliable. + return parseCaipAccountId(`eip155:1:${address}`); + } + + return parseCaipAccountId(address as CaipAccountId); + }, [address]); + const useBlockie = useSelector(getUseBlockie); + + // For EVM addresses, we make sure they are checksummed. + const transformedAddress = + parsed.chain.namespace === 'eip155' + ? toChecksumHexAddress(parsed.address) + : parsed.address; + const shortenedAddress = shortenAddress(transformedAddress); + + return ( + + {useBlockie ? ( + + ) : ( + + )} + {shortenedAddress} + + ); +}; diff --git a/ui/components/app/snaps/snap-ui-renderer/components/address.ts b/ui/components/app/snaps/snap-ui-renderer/components/address.ts index ce7128fad9dd..108ff37f33a5 100644 --- a/ui/components/app/snaps/snap-ui-renderer/components/address.ts +++ b/ui/components/app/snaps/snap-ui-renderer/components/address.ts @@ -2,9 +2,9 @@ import { AddressElement } from '@metamask/snaps-sdk/jsx'; import { UIComponentFactory } from './types'; export const address: UIComponentFactory = ({ element }) => ({ - element: 'ConfirmInfoRowAddress', + element: 'SnapUIAddress', props: { address: element.props.address, - isSnapUsingThis: true, + diameter: 16, }, }); diff --git a/ui/components/ui/jazzicon/jazzicon.component.tsx b/ui/components/ui/jazzicon/jazzicon.component.tsx index 4cb84851c9e6..f014d321ecc4 100644 --- a/ui/components/ui/jazzicon/jazzicon.component.tsx +++ b/ui/components/ui/jazzicon/jazzicon.component.tsx @@ -1,8 +1,29 @@ import React, { useEffect, useRef } from 'react'; import jazzicon from '@metamask/jazzicon'; -import iconFactoryGenerator from '../../../helpers/utils/icon-factory'; +import { stringToBytes } from '@metamask/utils'; +import iconFactoryGenerator, { + IconFactory, +} from '../../../helpers/utils/icon-factory'; -const iconFactory = iconFactoryGenerator(jazzicon); +/** + * Generates a seed for Jazzicon based on the provided address. + * + * Our existing seed generation for Ethereum addresses does not work with + * arbitrary string inputs. Since it assumes the address can be parsed as + * hexadecimal, however that assumption does not hold for all multichain + * addresses. Therefore we choose to use a byte array as the seed for multichain + * addresses. This works since the underlying Mersenne Twister PRNG can be + * seeded with an array as well. + * + * @param address - The blockchain address to generate the seed for. + * @returns The seed for Jazzicon. + */ +function generateSeed(address: string) { + return Array.from(stringToBytes(address.normalize('NFKC').toLowerCase())); +} + +const ethereumIconFactory = iconFactoryGenerator(jazzicon); +const multichainIconFactory = new IconFactory(jazzicon, generateSeed); /** * Renders a Jazzicon component based on the provided address. Utilizes a React ref to manage the DOM element for the icon. @@ -13,6 +34,7 @@ const iconFactory = iconFactoryGenerator(jazzicon); * @param props.diameter - Optional. The diameter of the icon. Defaults to 46 pixels. * @param props.style - Optional. Inline styles for the container div. * @param props.tokenList - Optional. An object mapping addresses to token metadata, used to optionally override Jazzicon with specific icons. + * @param props.namespace - Optional. The namespace to use for the seed generation. Defaults to 'eip155'. * @returns A React component displaying a Jazzicon or custom icon. */ function Jazzicon({ @@ -21,12 +43,14 @@ function Jazzicon({ diameter = 46, style, tokenList = {}, + namespace = 'eip155', }: { address: string; className?: string; diameter?: number; style?: React.CSSProperties; tokenList?: { [address: string]: { iconUrl?: string } }; + namespace?: string; }) { const container = useRef(null); @@ -36,6 +60,9 @@ function Jazzicon({ return; } + const iconFactory = + namespace === 'eip155' ? ethereumIconFactory : multichainIconFactory; + const imageNode = iconFactory.iconForAddress( address, diameter, diff --git a/ui/components/ui/jazzicon/jazzicon.d.ts b/ui/components/ui/jazzicon/jazzicon.d.ts index 4e52a47810a9..b235733d0bda 100644 --- a/ui/components/ui/jazzicon/jazzicon.d.ts +++ b/ui/components/ui/jazzicon/jazzicon.d.ts @@ -1,4 +1,4 @@ declare module '@metamask/jazzicon' { - function jazzicon(diameter: number, seed: number): SVGSVGElement; + function jazzicon(diameter: number, seed: number | number[]): SVGSVGElement; export default jazzicon; } diff --git a/ui/helpers/utils/icon-factory.ts b/ui/helpers/utils/icon-factory.ts index a19d75fe8801..411bdad6f3db 100644 --- a/ui/helpers/utils/icon-factory.ts +++ b/ui/helpers/utils/icon-factory.ts @@ -8,15 +8,22 @@ type TokenMetadata = { iconUrl: string; }; +type GenerateSeedFunction = (address: string) => number | number[]; + /** * A factory for generating icons for cryptocurrency addresses using Jazzicon or predefined token metadata. */ -class IconFactory { +export class IconFactory { /** * Function to generate a Jazzicon SVG element. */ jazzicon: typeof Jazzicon; + /** + * Function to generate seed before passing to jazzicon implementation. + */ + generateSeed: GenerateSeedFunction; + /** * Cache for storing generated SVG elements to avoid re-rendering. */ @@ -26,9 +33,14 @@ class IconFactory { * Constructs an IconFactory instance with a given Jazzicon function. * * @param jazzicon - A function that returns a Jazzicon SVG given a diameter and seed. + * @param generateSeed - An optional function that generates a seed based on an address. */ - constructor(jazzicon: typeof Jazzicon) { + constructor( + jazzicon: typeof Jazzicon, + generateSeed: GenerateSeedFunction = jsNumberForAddress, + ) { this.jazzicon = jazzicon; + this.generateSeed = generateSeed; this.cache = {}; } @@ -76,7 +88,7 @@ class IconFactory { * @returns A new Jazzicon SVG element. */ generateNewIdenticon(address: string, diameter: number): SVGSVGElement { - const numericRepresentation = jsNumberForAddress(address); + const numericRepresentation = this.generateSeed(address); const identicon = this.jazzicon(diameter, numericRepresentation); return identicon; }