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;
}