-
-
-
+ />
diff --git a/ui/components/app/name/name-details/name-details.test.tsx b/ui/components/app/name/name-details/name-details.test.tsx
index 9e93384adcd6..0f0df9f5b5f6 100644
--- a/ui/components/app/name/name-details/name-details.test.tsx
+++ b/ui/components/app/name/name-details/name-details.test.tsx
@@ -11,8 +11,8 @@ import {
MetaMetricsEventCategory,
MetaMetricsEventName,
} from '../../../../../shared/constants/metametrics';
-import { mockNetworkState } from '../../../../../test/stub/networks';
import { CHAIN_IDS } from '../../../../../shared/constants/network';
+import { mockNetworkState } from '../../../../../test/stub/networks';
import NameDetails from './name-details';
jest.mock('../../../../store/actions', () => ({
@@ -37,11 +37,11 @@ const SOURCE_ID_MOCK = 'ens';
const SOURCE_ID_2_MOCK = 'some_snap';
const PROPOSED_NAME_MOCK = 'TestProposedName';
const PROPOSED_NAME_2_MOCK = 'TestProposedName2';
+const VARIATION_MOCK = CHAIN_ID_MOCK;
const STATE_MOCK = {
metamask: {
...mockNetworkState({ chainId: CHAIN_IDS.MAINNET }),
-
nameSources: {
[SOURCE_ID_2_MOCK]: { label: 'Super Name Resolution Snap' },
},
@@ -85,13 +85,17 @@ const STATE_MOCK = {
},
},
useTokenDetection: true,
- tokenList: {
- '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d': {
- address: '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d',
- symbol: 'IUSD',
- name: 'iZUMi Bond USD',
- iconUrl:
- 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d.png',
+ tokensChainsCache: {
+ [VARIATION_MOCK]: {
+ data: {
+ '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d': {
+ address: '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d',
+ symbol: 'IUSD',
+ name: 'iZUMi Bond USD',
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d.png',
+ },
+ },
},
},
},
@@ -157,6 +161,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -170,6 +175,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -183,6 +189,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -196,6 +203,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -209,6 +217,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -229,6 +238,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -251,6 +261,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -273,6 +284,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -295,6 +307,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -317,6 +330,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -336,6 +350,7 @@ describe('NameDetails', () => {
undefined}
/>,
store,
@@ -373,6 +388,7 @@ describe('NameDetails', () => {
undefined}
/>
,
@@ -399,6 +415,7 @@ describe('NameDetails', () => {
undefined}
/>
,
@@ -426,6 +443,7 @@ describe('NameDetails', () => {
undefined}
/>
,
@@ -454,6 +472,7 @@ describe('NameDetails', () => {
undefined}
/>
,
diff --git a/ui/components/app/name/name-details/name-details.tsx b/ui/components/app/name/name-details/name-details.tsx
index 22b0445a0ad5..1bb2b1f1e478 100644
--- a/ui/components/app/name/name-details/name-details.tsx
+++ b/ui/components/app/name/name-details/name-details.tsx
@@ -46,7 +46,7 @@ import Name from '../name';
import FormComboField, {
FormComboFieldOption,
} from '../../../ui/form-combo-field/form-combo-field';
-import { getCurrentChainId, getNameSources } from '../../../../selectors';
+import { getNameSources } from '../../../../selectors';
import {
setName as saveName,
updateProposedNames,
@@ -64,6 +64,7 @@ export type NameDetailsProps = {
sourcePriority?: string[];
type: NameType;
value: string;
+ variation: string;
};
type ProposedNameOption = Required & {
@@ -157,12 +158,14 @@ function getInitialSources(
return [...resultSources, ...stateSources].sort();
}
-function useProposedNames(value: string, type: NameType, chainId: string) {
+function useProposedNames(value: string, type: NameType, variation: string) {
const dispatch = useDispatch();
- const { proposedNames } = useName(value, type);
+ const { proposedNames } = useName(value, type, variation);
+
// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateInterval = useRef();
+
const [initialSources, setInitialSources] = useState();
useEffect(() => {
@@ -178,7 +181,7 @@ function useProposedNames(value: string, type: NameType, chainId: string) {
value,
type,
onlyUpdateAfterDelay: true,
- variation: chainId,
+ variation,
}),
// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -196,7 +199,7 @@ function useProposedNames(value: string, type: NameType, chainId: string) {
updateInterval.current = setInterval(update, UPDATE_DELAY);
return reset;
- }, [value, type, chainId, dispatch, initialSources, setInitialSources]);
+ }, [value, type, variation, dispatch, initialSources, setInitialSources]);
return { proposedNames, initialSources };
}
@@ -205,13 +208,20 @@ export default function NameDetails({
onClose,
type,
value,
+ variation,
}: NameDetailsProps) {
- const chainId = useSelector(getCurrentChainId);
- const { name: savedPetname, sourceId: savedSourceId } = useName(value, type);
- const { name: displayName, hasPetname: hasSavedPetname } = useDisplayName(
+ const { name: savedPetname, sourceId: savedSourceId } = useName(
value,
type,
+ variation,
);
+
+ const { name: displayName, hasPetname: hasSavedPetname } = useDisplayName({
+ value,
+ type,
+ variation,
+ });
+
const nameSources = useSelector(getNameSources, isEqual);
const [name, setName] = useState('');
const [openMetricSent, setOpenMetricSent] = useState(false);
@@ -226,7 +236,7 @@ export default function NameDetails({
const { proposedNames, initialSources } = useProposedNames(
value,
type,
- chainId,
+ variation,
);
const [copiedAddress, handleCopyAddress] = useCopyToClipboard() as [
@@ -275,12 +285,12 @@ export default function NameDetails({
type,
name: name?.length ? name : null,
sourceId: selectedSourceId,
- variation: chainId,
+ variation,
}),
);
onClose();
- }, [name, selectedSourceId, onClose, trackPetnamesSaveEvent, chainId]);
+ }, [name, selectedSourceId, onClose, trackPetnamesSaveEvent, variation]);
const handleClose = useCallback(() => {
onClose();
@@ -333,6 +343,7 @@ export default function NameDetails({
diff --git a/ui/components/app/name/name.stories.tsx b/ui/components/app/name/name.stories.tsx
index fb23334a8776..732c9059b530 100644
--- a/ui/components/app/name/name.stories.tsx
+++ b/ui/components/app/name/name.stories.tsx
@@ -3,108 +3,75 @@ import React from 'react';
import { NameType } from '@metamask/name-controller';
import { Provider } from 'react-redux';
import configureStore from '../../../store/store';
-import Name from './name';
-import { mockNetworkState } from '../../../../test/stub/networks';
+import Name, { NameProps } from './name';
+import mockState from '../../../../test/data/mock-state.json';
+import {
+ EXPERIENCES_TYPE,
+ FIRST_PARTY_CONTRACT_NAMES,
+} from '../../../../shared/constants/first-party-contracts';
+import { cloneDeep } from 'lodash';
-const addressNoSavedNameMock = '0xc0ffee254729296a45a3885639ac7e10f9d54978';
-const addressSavedNameMock = '0xc0ffee254729296a45a3885639ac7e10f9d54977';
-const addressSavedTokenMock = '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d';
-const addressUnsavedTokenMock = '0x0a5e677a6a24b2f1a2bf4f3bffc443231d2fdec8';
-const chainIdMock = '0x1';
+const ADDRESS_MOCK = '0xc0ffee254729296a45a3885639ac7e10f9d54978';
+const ADDRESS_NFT_MOCK = '0xc0ffee254729296a45a3885639ac7e10f9d54979';
+const VARIATION_MOCK = '0x1';
+const NAME_MOCK = 'Saved Name';
-const storeMock = configureStore({
+const ADDRESS_FIRST_PARTY_MOCK =
+ FIRST_PARTY_CONTRACT_NAMES[EXPERIENCES_TYPE.METAMASK_BRIDGE][
+ VARIATION_MOCK
+ ].toLowerCase();
+
+const PROPOSED_NAMES_MOCK = {
+ ens: {
+ proposedNames: ['test.eth'],
+ lastRequestTime: 123,
+ retryDelay: null,
+ },
+ etherscan: {
+ proposedNames: ['TestContract'],
+ lastRequestTime: 123,
+ retryDelay: null,
+ },
+ token: {
+ proposedNames: ['Test Token'],
+ lastRequestTime: 123,
+ retryDelay: null,
+ },
+ lens: {
+ proposedNames: ['test.lens'],
+ lastRequestTime: 123,
+ retryDelay: null,
+ },
+};
+
+const STATE_MOCK = {
+ ...mockState,
metamask: {
- ...mockNetworkState({chainId: chainIdMock}),
+ ...mockState.metamask,
useTokenDetection: true,
- tokenList: {
- '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d': {
- address: '0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d',
- symbol: 'IUSD',
- name: 'iZUMi Bond USD',
- iconUrl:
- 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d.png',
- },
- '0x0a5e677a6a24b2f1a2bf4f3bffc443231d2fdec8': {
- address: '0x0a5e677a6a24b2f1a2bf4f3bffc443231d2fdec8',
- symbol: 'USX',
- name: 'dForce USD',
- iconUrl:
- 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a5e677a6a24b2f1a2bf4f3bffc443231d2fdec8.png',
- },
- },
+ tokensChainsCache: {},
names: {
[NameType.ETHEREUM_ADDRESS]: {
- [addressNoSavedNameMock]: {
- [chainIdMock]: {
- proposedNames: {
- ens: {
- proposedNames: ['test.eth'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- etherscan: {
- proposedNames: ['TestContract'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- token: {
- proposedNames: ['Test Token'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- lens: {
- proposedNames: ['test.lens'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- },
+ [ADDRESS_MOCK]: {
+ [VARIATION_MOCK]: {
+ proposedNames: PROPOSED_NAMES_MOCK,
},
},
- [addressSavedNameMock]: {
- [chainIdMock]: {
- proposedNames: {
- ens: {
- proposedNames: ['test.eth'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- etherscan: {
- proposedNames: ['TestContract'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- token: {
- proposedNames: ['Test Token'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- lens: {
- proposedNames: ['test.lens'],
- lastRequestTime: 123,
- retryDelay: null,
- },
- },
- name: 'Test Token',
- sourceId: 'token',
+ [ADDRESS_NFT_MOCK]: {
+ [VARIATION_MOCK]: {
+ proposedNames: PROPOSED_NAMES_MOCK,
},
},
- [addressSavedTokenMock]: {
- [chainIdMock]: {
- proposedNames: {},
- name: 'Saved Token Name',
- sourceId: 'token',
+ [ADDRESS_FIRST_PARTY_MOCK]: {
+ [VARIATION_MOCK]: {
+ proposedNames: PROPOSED_NAMES_MOCK,
},
},
},
},
- nameSources: {
- ens: { label: 'Ethereum Name Service (ENS)' },
- etherscan: { label: 'Etherscan (Verified Contract Name)' },
- token: { label: 'Blockchain (Token Name)' },
- lens: { label: 'Lens Protocol' },
- },
+ nameSources: {},
},
-});
+};
/**
* Displays the saved name for a raw value such as an Ethereum address.
@@ -125,6 +92,10 @@ export default {
description: `The type of value.
Limited to the values in the \`NameType\` enum.`,
},
+ variation: {
+ control: 'text',
+ description: `The variation of the value.
For example, the chain ID if the type is Ethereum address.`,
+ },
disableEdit: {
control: 'boolean',
description: `Whether to prevent the modal from opening when the component is clicked.`,
@@ -134,68 +105,141 @@ export default {
},
},
args: {
- value: addressNoSavedNameMock,
+ value: ADDRESS_MOCK,
type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
disableEdit: false,
},
- decorators: [(story) => {story()}],
+ render: ({ state, ...args }) => {
+ const finalState = cloneDeep(STATE_MOCK);
+ state?.(finalState);
+
+ return (
+
+
+
+ );
+ },
};
-// eslint-disable-next-line jsdoc/require-param
/**
* No name has been saved for the value and type.
*/
-export const DefaultStory = (args) => {
- return ;
+export const NoSavedName = {
+ name: 'No Saved Name',
+ args: {
+ value: ADDRESS_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ },
};
-DefaultStory.storyName = 'No Saved Name';
-
/**
* A name was previously saved for this value and type.
* The component will still display a modal when clicked to edit the name.
*/
-export const SavedNameStory = () => {
- return ;
+export const SavedNameStory = {
+ name: 'Saved Name',
+ args: {
+ value: ADDRESS_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ state: (state) => {
+ state.metamask.names[NameType.ETHEREUM_ADDRESS][ADDRESS_MOCK][
+ VARIATION_MOCK
+ ].name = NAME_MOCK;
+ },
+ },
};
-SavedNameStory.storyName = 'Saved Name';
-
/**
* No name was previously saved for this recognized token.
* The component will still display a modal when clicked to edit the name.
*/
-export const UnsavedTokenNameStory = () => {
- return (
-
- );
+export const DefaultTokenNameStory = {
+ name: 'Default ERC-20 Token Name',
+ args: {
+ value: ADDRESS_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ state: (state) => {
+ state.metamask.tokensChainsCache = {
+ [VARIATION_MOCK]: {
+ data: {
+ [ADDRESS_MOCK]: {
+ address: ADDRESS_MOCK,
+ symbol: 'IUSD',
+ name: 'iZUMi Bond USD',
+ iconUrl:
+ 'https://static.cx.metamask.io/api/v1/tokenIcons/1/0x0a3bb08b3a15a19b4de82f8acfc862606fb69a2d.png',
+ },
+ },
+ },
+ };
+ },
+ },
};
-UnsavedTokenNameStory.storyName = 'Unsaved Token Name';
+/**
+ * No name was previously saved for this watched NFT.
+ * The component will still display a modal when clicked to edit the name.
+ */
+export const DefaultWatchedNFTNameStory = {
+ name: 'Default Watched NFT Name',
+ args: {
+ value: ADDRESS_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ state: (state) => {
+ state.metamask.allNftContracts = {
+ '0x123': {
+ [VARIATION_MOCK]: [
+ {
+ address: ADDRESS_MOCK,
+ name: 'Everything I Own',
+ },
+ ],
+ },
+ };
+ },
+ },
+};
/**
- * A name was previously saved for this recognized token.
+ * No name was previously saved for this recognized NFT.
* The component will still display a modal when clicked to edit the name.
*/
-export const SavedTokenNameStory = () => {
- return (
-
- );
+export const DefaultNFTNameStory = {
+ name: 'Default NFT Name',
+ args: {
+ value: ADDRESS_NFT_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ },
};
-SavedTokenNameStory.storyName = 'Saved Token Name';
+/**
+ * No name was previously saved for this first-party contract.
+ * The component will still display a modal when clicked to edit the name.
+ */
+export const DefaultFirstPartyNameStory = {
+ name: 'Default First-Party Name',
+ args: {
+ value: ADDRESS_FIRST_PARTY_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ },
+};
/**
* Clicking the component will not display a modal to edit the name.
*/
-export const EditDisabledStory = () => {
- return (
-
- );
+export const EditDisabledStory = {
+ name: 'Edit Disabled',
+ args: {
+ value: ADDRESS_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ disableEdit: true,
+ },
};
-
-EditDisabledStory.storyName = 'Edit Disabled';
diff --git a/ui/components/app/name/name.test.tsx b/ui/components/app/name/name.test.tsx
index 061d39e670de..33648e98e38c 100644
--- a/ui/components/app/name/name.test.tsx
+++ b/ui/components/app/name/name.test.tsx
@@ -22,6 +22,7 @@ jest.mock('react-redux', () => ({
const ADDRESS_NO_SAVED_NAME_MOCK = '0xc0ffee254729296a45a3885639ac7e10f9d54977';
const ADDRESS_SAVED_NAME_MOCK = '0xc0ffee254729296a45a3885639ac7e10f9d54979';
const SAVED_NAME_MOCK = 'TestName';
+const VARIATION_MOCK = 'testVariation';
const STATE_MOCK = {
metamask: {
@@ -44,7 +45,11 @@ describe('Name', () => {
});
const { container } = renderWithProvider(
- ,
+ ,
store,
);
@@ -61,6 +66,7 @@ describe('Name', () => {
,
store,
);
@@ -75,7 +81,11 @@ describe('Name', () => {
});
const { container } = renderWithProvider(
- ,
+ ,
store,
);
@@ -90,7 +100,11 @@ describe('Name', () => {
});
const { container } = renderWithProvider(
- ,
+ ,
store,
);
@@ -114,7 +128,11 @@ describe('Name', () => {
renderWithProvider(
-
+
,
store,
);
diff --git a/ui/components/app/name/name.tsx b/ui/components/app/name/name.tsx
index 5af2851c8885..2097d21faf07 100644
--- a/ui/components/app/name/name.tsx
+++ b/ui/components/app/name/name.tsx
@@ -38,6 +38,12 @@ export type NameProps = {
/** The raw value to display the name of. */
value: string;
+
+ /**
+ * The variation of the value.
+ * Such as the chain ID if the `type` is an Ethereum address.
+ */
+ variation: string;
};
function formatValue(value: string, type: NameType): string {
@@ -61,15 +67,17 @@ const Name = memo(
disableEdit,
internal,
preferContractSymbol = false,
+ variation,
}: NameProps) => {
const [modalOpen, setModalOpen] = useState(false);
const trackEvent = useContext(MetaMetricsContext);
- const { name, hasPetname, image } = useDisplayName(
+ const { name, hasPetname, image } = useDisplayName({
value,
type,
preferContractSymbol,
- );
+ variation,
+ });
useEffect(() => {
if (internal) {
@@ -100,7 +108,12 @@ const Name = memo(
return (
{!disableEdit && modalOpen && (
-
+
)}
{
const tooltipTitle = await waitFor(() =>
container.querySelector(
- '[data-original-title="This account is not set up for use with goerli"]',
+ '[data-original-title="This account is not set up for use with Goerli"]',
),
);
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
index d29236409dbc..7dd3749a6e5f 100644
--- 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
@@ -3,7 +3,7 @@
exports[`SnapUIAddress renders Bitcoin address 1`] = `
= ({
[caipIdentifier],
);
- // For EVM addresses, we make sure they are checksummed.
- const transformedAddress =
- parsed.chain.namespace === 'eip155'
- ? toChecksumHexAddress(parsed.address)
- : parsed.address;
- const shortenedAddress = shortenAddress(transformedAddress);
+ const displayName = useDisplayName(parsed);
+
+ const value =
+ displayName ??
+ shortenAddress(
+ parsed.chain.namespace === 'eip155'
+ ? toChecksumHexAddress(parsed.address)
+ : parsed.address,
+ );
return (
-
+
- {shortenedAddress}
+ {value}
);
};
diff --git a/ui/components/app/snaps/snap-ui-link/index.scss b/ui/components/app/snaps/snap-ui-link/index.scss
new file mode 100644
index 000000000000..7d3f75f0e372
--- /dev/null
+++ b/ui/components/app/snaps/snap-ui-link/index.scss
@@ -0,0 +1,11 @@
+.snap-ui-renderer__link {
+ & .snap-ui-renderer__address {
+ // Fixes an issue where the link end icon would wrap
+ display: inline-flex;
+ }
+
+ .snap-ui-renderer__address + .mm-icon {
+ // This fixes an issue where the icon would be misaligned with the Address component
+ top: 0;
+ }
+}
diff --git a/ui/components/app/snaps/snap-ui-link/snap-ui-link.js b/ui/components/app/snaps/snap-ui-link/snap-ui-link.js
index a1289543fd45..58a22008a52a 100644
--- a/ui/components/app/snaps/snap-ui-link/snap-ui-link.js
+++ b/ui/components/app/snaps/snap-ui-link/snap-ui-link.js
@@ -34,7 +34,7 @@ export const SnapUILink = ({ href, children }) => {
{children}
@@ -51,7 +51,14 @@ export const SnapUILink = ({ href, children }) => {
externalLink
size={ButtonLinkSize.Inherit}
display={Display.Inline}
- className="snap-ui-link"
+ className="snap-ui-renderer__link"
+ style={{
+ // Prevents the link from taking up the full width of the parent.
+ width: 'fit-content',
+ }}
+ textProps={{
+ display: Display.Inline,
+ }}
>
{children}
diff --git a/ui/components/app/snaps/snap-ui-renderer/index.scss b/ui/components/app/snaps/snap-ui-renderer/index.scss
index 7e18e72c917f..d32edf726479 100644
--- a/ui/components/app/snaps/snap-ui-renderer/index.scss
+++ b/ui/components/app/snaps/snap-ui-renderer/index.scss
@@ -34,6 +34,10 @@
border-radius: 8px;
border-color: var(--color-border-muted);
+ & .mm-icon {
+ top: 0;
+ }
+
.mm-text--overflow-wrap-anywhere {
overflow-wrap: normal;
}
@@ -48,10 +52,6 @@
&__panel {
gap: 8px;
-
- .mm-icon--size-inherit {
- top: 0;
- }
}
&__text {
diff --git a/ui/components/app/toast-master/selectors.ts b/ui/components/app/toast-master/selectors.ts
new file mode 100644
index 000000000000..b88762c3bc19
--- /dev/null
+++ b/ui/components/app/toast-master/selectors.ts
@@ -0,0 +1,108 @@
+import { InternalAccount, isEvmAccountType } from '@metamask/keyring-api';
+import { getAlertEnabledness } from '../../../ducks/metamask/metamask';
+import { PRIVACY_POLICY_DATE } from '../../../helpers/constants/privacy-policy';
+import {
+ SURVEY_DATE,
+ SURVEY_END_TIME,
+ SURVEY_START_TIME,
+} from '../../../helpers/constants/survey';
+import { getPermittedAccountsForCurrentTab } from '../../../selectors';
+import { MetaMaskReduxState } from '../../../store/store';
+import { getIsPrivacyToastRecent } from './utils';
+
+// TODO: get this into one of the larger definitions of state type
+type State = Omit & {
+ appState: {
+ showNftDetectionEnablementToast?: boolean;
+ };
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed?: boolean;
+ newPrivacyPolicyToastShownDate?: number;
+ onboardingDate?: number;
+ showNftDetectionEnablementToast?: boolean;
+ surveyLinkLastClickedOrClosed?: number;
+ switchedNetworkNeverShowMessage?: boolean;
+ };
+};
+
+/**
+ * Determines if the survey toast should be shown based on the current time, survey start and end times, and whether the survey link was last clicked or closed.
+ *
+ * @param state - The application state containing the necessary survey data.
+ * @returns True if the current time is between the survey start and end times and the survey link was not last clicked or closed. False otherwise.
+ */
+export function selectShowSurveyToast(state: State): boolean {
+ if (state.metamask?.surveyLinkLastClickedOrClosed) {
+ return false;
+ }
+
+ const startTime = new Date(`${SURVEY_DATE} ${SURVEY_START_TIME}`).getTime();
+ const endTime = new Date(`${SURVEY_DATE} ${SURVEY_END_TIME}`).getTime();
+ const now = Date.now();
+
+ return now > startTime && now < endTime;
+}
+
+/**
+ * Determines if the privacy policy toast should be shown based on the current date and whether the new privacy policy toast was clicked or closed.
+ *
+ * @param state - The application state containing the privacy policy data.
+ * @returns Boolean is True if the toast should be shown, and the number is the date the toast was last shown.
+ */
+export function selectShowPrivacyPolicyToast(state: State): {
+ showPrivacyPolicyToast: boolean;
+ newPrivacyPolicyToastShownDate?: number;
+} {
+ const {
+ newPrivacyPolicyToastClickedOrClosed,
+ newPrivacyPolicyToastShownDate,
+ onboardingDate,
+ } = state.metamask || {};
+ const newPrivacyPolicyDate = new Date(PRIVACY_POLICY_DATE);
+ const currentDate = new Date(Date.now());
+
+ const showPrivacyPolicyToast =
+ !newPrivacyPolicyToastClickedOrClosed &&
+ currentDate >= newPrivacyPolicyDate &&
+ getIsPrivacyToastRecent(newPrivacyPolicyToastShownDate) &&
+ // users who onboarded before the privacy policy date should see the notice
+ // and
+ // old users who don't have onboardingDate set should see the notice
+ (!onboardingDate || onboardingDate < newPrivacyPolicyDate.valueOf());
+
+ return { showPrivacyPolicyToast, newPrivacyPolicyToastShownDate };
+}
+
+export function selectNftDetectionEnablementToast(state: State): boolean {
+ return Boolean(state.appState?.showNftDetectionEnablementToast);
+}
+
+// If there is more than one connected account to activeTabOrigin,
+// *BUT* the current account is not one of them, show the banner
+export function selectShowConnectAccountToast(
+ state: State,
+ account: InternalAccount,
+): boolean {
+ const allowShowAccountSetting = getAlertEnabledness(state).unconnectedAccount;
+ const connectedAccounts = getPermittedAccountsForCurrentTab(state);
+ const isEvmAccount = isEvmAccountType(account?.type);
+
+ return (
+ allowShowAccountSetting &&
+ account &&
+ state.activeTab?.origin &&
+ isEvmAccount &&
+ connectedAccounts.length > 0 &&
+ !connectedAccounts.some((address) => address === account.address)
+ );
+}
+
+/**
+ * Retrieves user preference to never see the "Switched Network" toast
+ *
+ * @param state - Redux state object.
+ * @returns Boolean preference value
+ */
+export function selectSwitchedNetworkNeverShowMessage(state: State): boolean {
+ return Boolean(state.metamask.switchedNetworkNeverShowMessage);
+}
diff --git a/ui/components/app/toast-master/toast-master.js b/ui/components/app/toast-master/toast-master.js
new file mode 100644
index 000000000000..584f1cc25983
--- /dev/null
+++ b/ui/components/app/toast-master/toast-master.js
@@ -0,0 +1,299 @@
+/* eslint-disable react/prop-types -- TODO: upgrade to TypeScript */
+
+import React, { useState } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { useHistory, useLocation } from 'react-router-dom';
+import { MILLISECOND, SECOND } from '../../../../shared/constants/time';
+import {
+ PRIVACY_POLICY_LINK,
+ SURVEY_LINK,
+} from '../../../../shared/lib/ui-utils';
+import {
+ BorderColor,
+ BorderRadius,
+ IconColor,
+ TextVariant,
+} from '../../../helpers/constants/design-system';
+import {
+ DEFAULT_ROUTE,
+ REVIEW_PERMISSIONS,
+} from '../../../helpers/constants/routes';
+import { getURLHost } from '../../../helpers/utils/util';
+import { useI18nContext } from '../../../hooks/useI18nContext';
+import { usePrevious } from '../../../hooks/usePrevious';
+import {
+ getCurrentNetwork,
+ getOriginOfCurrentTab,
+ getSelectedAccount,
+ getSwitchedNetworkDetails,
+ getUseNftDetection,
+} from '../../../selectors';
+import {
+ addPermittedAccount,
+ clearSwitchedNetworkDetails,
+ hidePermittedNetworkToast,
+} from '../../../store/actions';
+import {
+ AvatarAccount,
+ AvatarAccountSize,
+ AvatarNetwork,
+ Icon,
+ IconName,
+} from '../../component-library';
+import { Toast, ToastContainer } from '../../multichain';
+import { SurveyToast } from '../../ui/survey-toast';
+import {
+ selectNftDetectionEnablementToast,
+ selectShowConnectAccountToast,
+ selectShowPrivacyPolicyToast,
+ selectShowSurveyToast,
+ selectSwitchedNetworkNeverShowMessage,
+} from './selectors';
+import {
+ setNewPrivacyPolicyToastClickedOrClosed,
+ setNewPrivacyPolicyToastShownDate,
+ setShowNftDetectionEnablementToast,
+ setSurveyLinkLastClickedOrClosed,
+ setSwitchedNetworkNeverShowMessage,
+} from './utils';
+
+export function ToastMaster() {
+ const location = useLocation();
+
+ const onHomeScreen = location.pathname === DEFAULT_ROUTE;
+
+ return (
+ onHomeScreen && (
+
+
+
+
+
+
+
+
+
+ )
+ );
+}
+
+function ConnectAccountToast() {
+ const t = useI18nContext();
+ const dispatch = useDispatch();
+
+ const [hideConnectAccountToast, setHideConnectAccountToast] = useState(false);
+ const account = useSelector(getSelectedAccount);
+
+ // If the account has changed, allow the connect account toast again
+ const prevAccountAddress = usePrevious(account?.address);
+ if (account?.address !== prevAccountAddress && hideConnectAccountToast) {
+ setHideConnectAccountToast(false);
+ }
+
+ const showConnectAccountToast = useSelector((state) =>
+ selectShowConnectAccountToast(state, account),
+ );
+
+ const activeTabOrigin = useSelector(getOriginOfCurrentTab);
+
+ return (
+ Boolean(!hideConnectAccountToast && showConnectAccountToast) && (
+
+ }
+ text={t('accountIsntConnectedToastText', [
+ account?.metadata?.name,
+ getURLHost(activeTabOrigin),
+ ])}
+ actionText={t('connectAccount')}
+ onActionClick={() => {
+ // Connect this account
+ dispatch(addPermittedAccount(activeTabOrigin, account.address));
+ // Use setTimeout to prevent React re-render from
+ // hiding the tooltip
+ setTimeout(() => {
+ // Trigger a mouseenter on the header's connection icon
+ // to display the informative connection tooltip
+ document
+ .querySelector(
+ '[data-testid="connection-menu"] [data-tooltipped]',
+ )
+ ?.dispatchEvent(new CustomEvent('mouseenter', {}));
+ }, 250 * MILLISECOND);
+ }}
+ onClose={() => setHideConnectAccountToast(true)}
+ />
+ )
+ );
+}
+
+function SurveyToastMayDelete() {
+ const t = useI18nContext();
+
+ const showSurveyToast = useSelector(selectShowSurveyToast);
+
+ return (
+ showSurveyToast && (
+
+ }
+ text={t('surveyTitle')}
+ actionText={t('surveyConversion')}
+ onActionClick={() => {
+ global.platform.openTab({
+ url: SURVEY_LINK,
+ });
+ setSurveyLinkLastClickedOrClosed(Date.now());
+ }}
+ onClose={() => {
+ setSurveyLinkLastClickedOrClosed(Date.now());
+ }}
+ />
+ )
+ );
+}
+
+function PrivacyPolicyToast() {
+ const t = useI18nContext();
+
+ const { showPrivacyPolicyToast, newPrivacyPolicyToastShownDate } =
+ useSelector(selectShowPrivacyPolicyToast);
+
+ // If the privacy policy toast is shown, and there is no date set, set it
+ if (showPrivacyPolicyToast && !newPrivacyPolicyToastShownDate) {
+ setNewPrivacyPolicyToastShownDate(Date.now());
+ }
+
+ return (
+ showPrivacyPolicyToast && (
+
+ }
+ text={t('newPrivacyPolicyTitle')}
+ actionText={t('newPrivacyPolicyActionButton')}
+ onActionClick={() => {
+ global.platform.openTab({
+ url: PRIVACY_POLICY_LINK,
+ });
+ setNewPrivacyPolicyToastClickedOrClosed();
+ }}
+ onClose={setNewPrivacyPolicyToastClickedOrClosed}
+ />
+ )
+ );
+}
+
+function SwitchedNetworkToast() {
+ const t = useI18nContext();
+ const dispatch = useDispatch();
+
+ const switchedNetworkDetails = useSelector(getSwitchedNetworkDetails);
+ const switchedNetworkNeverShowMessage = useSelector(
+ selectSwitchedNetworkNeverShowMessage,
+ );
+
+ const isShown = switchedNetworkDetails && !switchedNetworkNeverShowMessage;
+
+ return (
+ isShown && (
+
+ }
+ text={t('switchedNetworkToastMessage', [
+ switchedNetworkDetails.nickname,
+ getURLHost(switchedNetworkDetails.origin),
+ ])}
+ actionText={t('switchedNetworkToastDecline')}
+ onActionClick={setSwitchedNetworkNeverShowMessage}
+ onClose={() => dispatch(clearSwitchedNetworkDetails())}
+ />
+ )
+ );
+}
+
+function NftEnablementToast() {
+ const t = useI18nContext();
+ const dispatch = useDispatch();
+
+ const showNftEnablementToast = useSelector(selectNftDetectionEnablementToast);
+ const useNftDetection = useSelector(getUseNftDetection);
+
+ const autoHideToastDelay = 5 * SECOND;
+
+ return (
+ showNftEnablementToast &&
+ useNftDetection && (
+
+ }
+ text={t('nftAutoDetectionEnabled')}
+ borderRadius={BorderRadius.LG}
+ textVariant={TextVariant.bodyMd}
+ autoHideTime={autoHideToastDelay}
+ onAutoHideToast={() =>
+ dispatch(setShowNftDetectionEnablementToast(false))
+ }
+ />
+ )
+ );
+}
+
+function PermittedNetworkToast() {
+ const t = useI18nContext();
+ const dispatch = useDispatch();
+
+ const isPermittedNetworkToastOpen = useSelector(
+ (state) => state.appState.showPermittedNetworkToastOpen,
+ );
+
+ const currentNetwork = useSelector(getCurrentNetwork);
+ const activeTabOrigin = useSelector(getOriginOfCurrentTab);
+ const safeEncodedHost = encodeURIComponent(activeTabOrigin);
+ const history = useHistory();
+
+ return (
+ isPermittedNetworkToastOpen && (
+
+ }
+ text={t('permittedChainToastUpdate', [
+ getURLHost(activeTabOrigin),
+ currentNetwork?.nickname,
+ ])}
+ actionText={t('editPermissions')}
+ onActionClick={() => {
+ dispatch(hidePermittedNetworkToast());
+ history.push(`${REVIEW_PERMISSIONS}/${safeEncodedHost}`);
+ }}
+ onClose={() => dispatch(hidePermittedNetworkToast())}
+ />
+ )
+ );
+}
diff --git a/ui/components/app/toast-master/toast-master.test.ts b/ui/components/app/toast-master/toast-master.test.ts
new file mode 100644
index 000000000000..8b29f20a240d
--- /dev/null
+++ b/ui/components/app/toast-master/toast-master.test.ts
@@ -0,0 +1,206 @@
+import { PRIVACY_POLICY_DATE } from '../../../helpers/constants/privacy-policy';
+import { SURVEY_DATE, SURVEY_GMT } from '../../../helpers/constants/survey';
+import {
+ selectShowPrivacyPolicyToast,
+ selectShowSurveyToast,
+} from './selectors';
+
+describe('#getShowSurveyToast', () => {
+ const realDateNow = Date.now;
+
+ afterEach(() => {
+ Date.now = realDateNow;
+ });
+
+ it('shows the survey link when not yet seen and within time bounds', () => {
+ Date.now = () =>
+ new Date(`${SURVEY_DATE} 12:25:00 ${SURVEY_GMT}`).getTime();
+ const result = selectShowSurveyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ surveyLinkLastClickedOrClosed: undefined,
+ },
+ });
+ expect(result).toStrictEqual(true);
+ });
+
+ it('does not show the survey link when seen and within time bounds', () => {
+ Date.now = () =>
+ new Date(`${SURVEY_DATE} 12:25:00 ${SURVEY_GMT}`).getTime();
+ const result = selectShowSurveyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ surveyLinkLastClickedOrClosed: 123456789,
+ },
+ });
+ expect(result).toStrictEqual(false);
+ });
+
+ it('does not show the survey link before time bounds', () => {
+ Date.now = () =>
+ new Date(`${SURVEY_DATE} 11:25:00 ${SURVEY_GMT}`).getTime();
+ const result = selectShowSurveyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ surveyLinkLastClickedOrClosed: undefined,
+ },
+ });
+ expect(result).toStrictEqual(false);
+ });
+
+ it('does not show the survey link after time bounds', () => {
+ Date.now = () =>
+ new Date(`${SURVEY_DATE} 14:25:00 ${SURVEY_GMT}`).getTime();
+ const result = selectShowSurveyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ surveyLinkLastClickedOrClosed: undefined,
+ },
+ });
+ expect(result).toStrictEqual(false);
+ });
+});
+
+describe('#getShowPrivacyPolicyToast', () => {
+ let dateNowSpy: jest.SpyInstance;
+
+ describe('mock one day after', () => {
+ beforeEach(() => {
+ const dayAfterPolicyDate = new Date(PRIVACY_POLICY_DATE);
+ dayAfterPolicyDate.setDate(dayAfterPolicyDate.getDate() + 1);
+
+ dateNowSpy = jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(dayAfterPolicyDate.getTime());
+ });
+
+ afterEach(() => {
+ dateNowSpy.mockRestore();
+ });
+
+ it('shows the privacy policy toast when not yet seen, on or after the policy date, and onboardingDate is before the policy date', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: new Date(PRIVACY_POLICY_DATE).setDate(
+ new Date(PRIVACY_POLICY_DATE).getDate() - 2,
+ ),
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(true);
+ });
+
+ it('does not show the privacy policy toast when seen, even if on or after the policy date and onboardingDate is before the policy date', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: true,
+ onboardingDate: new Date(PRIVACY_POLICY_DATE).setDate(
+ new Date(PRIVACY_POLICY_DATE).getDate() - 2,
+ ),
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(false);
+ });
+
+ it('shows the privacy policy toast when not yet seen, on or after the policy date, and onboardingDate is not set', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: undefined,
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(true);
+ });
+ });
+
+ describe('mock same day', () => {
+ beforeEach(() => {
+ dateNowSpy = jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(new Date(PRIVACY_POLICY_DATE).getTime());
+ });
+
+ afterEach(() => {
+ dateNowSpy.mockRestore();
+ });
+
+ it('shows the privacy policy toast when not yet seen, on or after the policy date, and onboardingDate is before the policy date', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: new Date(PRIVACY_POLICY_DATE).setDate(
+ new Date(PRIVACY_POLICY_DATE).getDate() - 2,
+ ),
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(true);
+ });
+
+ it('does not show the privacy policy toast when seen, even if on or after the policy date and onboardingDate is before the policy date', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: true,
+ onboardingDate: new Date(PRIVACY_POLICY_DATE).setDate(
+ new Date(PRIVACY_POLICY_DATE).getDate() - 2,
+ ),
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(false);
+ });
+
+ it('shows the privacy policy toast when not yet seen, on or after the policy date, and onboardingDate is not set', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: undefined,
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(true);
+ });
+ });
+
+ describe('mock day before', () => {
+ beforeEach(() => {
+ const dayBeforePolicyDate = new Date(PRIVACY_POLICY_DATE);
+ dayBeforePolicyDate.setDate(dayBeforePolicyDate.getDate() - 1);
+
+ dateNowSpy = jest
+ .spyOn(Date, 'now')
+ .mockReturnValue(dayBeforePolicyDate.getTime());
+ });
+
+ afterEach(() => {
+ dateNowSpy.mockRestore();
+ });
+
+ it('does not show the privacy policy toast before the policy date', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: new Date(PRIVACY_POLICY_DATE).setDate(
+ new Date(PRIVACY_POLICY_DATE).getDate() - 2,
+ ),
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(false);
+ });
+
+ it('does not show the privacy policy toast before the policy date even if onboardingDate is not set', () => {
+ const result = selectShowPrivacyPolicyToast({
+ // @ts-expect-error: intentionally passing incomplete input
+ metamask: {
+ newPrivacyPolicyToastClickedOrClosed: false,
+ onboardingDate: undefined,
+ },
+ });
+ expect(result.showPrivacyPolicyToast).toBe(false);
+ });
+ });
+});
diff --git a/ui/components/app/toast-master/utils.ts b/ui/components/app/toast-master/utils.ts
new file mode 100644
index 000000000000..d6544707f45d
--- /dev/null
+++ b/ui/components/app/toast-master/utils.ts
@@ -0,0 +1,69 @@
+import { PayloadAction } from '@reduxjs/toolkit';
+import { ReactFragment } from 'react';
+import { SHOW_NFT_DETECTION_ENABLEMENT_TOAST } from '../../../store/actionConstants';
+import { submitRequestToBackground } from '../../../store/background-connection';
+
+/**
+ * Returns true if the privacy policy toast was shown either never, or less than a day ago.
+ *
+ * @param newPrivacyPolicyToastShownDate
+ * @returns true if the privacy policy toast was shown either never, or less than a day ago
+ */
+export function getIsPrivacyToastRecent(
+ newPrivacyPolicyToastShownDate?: number,
+): boolean {
+ if (!newPrivacyPolicyToastShownDate) {
+ return true;
+ }
+
+ const currentDate = new Date();
+ const oneDayInMilliseconds = 24 * 60 * 60 * 1000;
+ const newPrivacyPolicyToastShownDateObj = new Date(
+ newPrivacyPolicyToastShownDate,
+ );
+ const toastWasShownLessThanADayAgo =
+ currentDate.valueOf() - newPrivacyPolicyToastShownDateObj.valueOf() <
+ oneDayInMilliseconds;
+
+ return toastWasShownLessThanADayAgo;
+}
+
+export function setNewPrivacyPolicyToastShownDate(time: number) {
+ submitRequestToBackgroundAndCatch('setNewPrivacyPolicyToastShownDate', [
+ time,
+ ]);
+}
+
+export function setNewPrivacyPolicyToastClickedOrClosed() {
+ submitRequestToBackgroundAndCatch('setNewPrivacyPolicyToastClickedOrClosed');
+}
+
+export function setShowNftDetectionEnablementToast(
+ value: boolean,
+): PayloadAction {
+ return {
+ type: SHOW_NFT_DETECTION_ENABLEMENT_TOAST,
+ payload: value,
+ };
+}
+
+export function setSwitchedNetworkNeverShowMessage() {
+ submitRequestToBackgroundAndCatch('setSwitchedNetworkNeverShowMessage', [
+ true,
+ ]);
+}
+
+export function setSurveyLinkLastClickedOrClosed(time: number) {
+ submitRequestToBackgroundAndCatch('setSurveyLinkLastClickedOrClosed', [time]);
+}
+
+// May move this to a different file after discussion with team
+export function submitRequestToBackgroundAndCatch(
+ method: string,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ args?: any[],
+) {
+ submitRequestToBackground(method, args)?.catch((error) => {
+ console.error('Error caught in submitRequestToBackground', error);
+ });
+}
diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
index 751b0f53e73a..226a2a9113c0 100644
--- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
+++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
@@ -214,7 +214,7 @@ export default class TransactionListItemDetails extends PureComponent {
primaryTransaction: transaction,
initialTransaction: { type },
} = transactionGroup;
- const { hash } = transaction;
+ const { chainId, hash } = transaction;
return (
@@ -332,6 +332,7 @@ export default class TransactionListItemDetails extends PureComponent {
recipientMetadataName={recipientMetadataName}
senderName={senderNickname}
senderAddress={senderAddress}
+ chainId={chainId}
onRecipientClick={() => {
this.context.trackEvent({
category: MetaMetricsEventCategory.Navigation,
diff --git a/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap b/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap
index b29efce542e3..4a9fc4d3cf7a 100644
--- a/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap
+++ b/ui/components/app/user-preferenced-currency-display/__snapshots__/user-preferenced-currency-display.test.js.snap
@@ -8,6 +8,7 @@ exports[`UserPreferencedCurrencyDisplay Component rendering should match snapsho
>
0
diff --git a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.d.ts b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.d.ts
index 4db61d568f4a..779309858a18 100644
--- a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.d.ts
+++ b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.d.ts
@@ -16,6 +16,7 @@ export type UserPrefrencedCurrencyDisplayProps = OverridingUnion<
showCurrencySuffix?: boolean;
shouldCheckShowNativeToken?: boolean;
isAggregatedFiatOverviewBalance?: boolean;
+ privacyMode?: boolean;
}
>;
diff --git a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js
index 613b731d0a16..a466f7813672 100644
--- a/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js
+++ b/ui/components/app/user-preferenced-currency-display/user-preferenced-currency-display.component.js
@@ -28,6 +28,7 @@ export default function UserPreferencedCurrencyDisplay({
showNative,
showCurrencySuffix,
shouldCheckShowNativeToken,
+ privacyMode = false,
...restProps
}) {
// NOTE: When displaying currencies, we need the actual account to detect whether we're in a
@@ -83,6 +84,7 @@ export default function UserPreferencedCurrencyDisplay({
numberOfDecimals={numberOfDecimals}
prefixComponent={prefixComponent}
suffix={showCurrencySuffix && !showEthLogo && currency}
+ privacyMode={privacyMode}
/>
);
}
@@ -126,6 +128,7 @@ const UserPreferencedCurrencyDisplayPropTypes = {
textProps: PropTypes.object,
suffixProps: PropTypes.object,
shouldCheckShowNativeToken: PropTypes.bool,
+ privacyMode: PropTypes.bool,
};
UserPreferencedCurrencyDisplay.propTypes =
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
index 95e0d92fa2b8..8da096151908 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.test.tsx
@@ -7,6 +7,7 @@ import {
getSelectedAccount,
getShouldHideZeroBalanceTokens,
getTokensMarketData,
+ getPreferences,
} from '../../../selectors';
import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance';
import { AggregatedPercentageOverview } from './aggregated-percentage-overview';
@@ -22,6 +23,7 @@ jest.mock('../../../ducks/locale/locale', () => ({
jest.mock('../../../selectors', () => ({
getCurrentCurrency: jest.fn(),
getSelectedAccount: jest.fn(),
+ getPreferences: jest.fn(),
getShouldHideZeroBalanceTokens: jest.fn(),
getTokensMarketData: jest.fn(),
}));
@@ -32,6 +34,7 @@ jest.mock('../../../hooks/useAccountTotalFiatBalance', () => ({
const mockGetIntlLocale = getIntlLocale as unknown as jest.Mock;
const mockGetCurrentCurrency = getCurrentCurrency as jest.Mock;
+const mockGetPreferences = getPreferences as jest.Mock;
const mockGetSelectedAccount = getSelectedAccount as unknown as jest.Mock;
const mockGetShouldHideZeroBalanceTokens =
getShouldHideZeroBalanceTokens as jest.Mock;
@@ -159,6 +162,7 @@ describe('AggregatedPercentageOverview', () => {
beforeEach(() => {
mockGetIntlLocale.mockReturnValue('en-US');
mockGetCurrentCurrency.mockReturnValue('USD');
+ mockGetPreferences.mockReturnValue({ privacyMode: false });
mockGetSelectedAccount.mockReturnValue(selectedAccountMock);
mockGetShouldHideZeroBalanceTokens.mockReturnValue(false);
mockGetTokensMarketData.mockReturnValue(marketDataMock);
diff --git a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
index 94555d3bc0cd..8c609610daa1 100644
--- a/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
+++ b/ui/components/app/wallet-overview/aggregated-percentage-overview.tsx
@@ -7,6 +7,7 @@ import {
getSelectedAccount,
getShouldHideZeroBalanceTokens,
getTokensMarketData,
+ getPreferences,
} from '../../../selectors';
import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance';
@@ -19,7 +20,7 @@ import {
TextColor,
TextVariant,
} from '../../../helpers/constants/design-system';
-import { Box, Text } from '../../component-library';
+import { Box, SensitiveText } from '../../component-library';
import { getCalculatedTokenAmount1dAgo } from '../../../helpers/utils/util';
// core already has this exported type but its not yet available in this version
@@ -34,6 +35,7 @@ export const AggregatedPercentageOverview = () => {
useSelector(getTokensMarketData);
const locale = useSelector(getIntlLocale);
const fiatCurrency = useSelector(getCurrentCurrency);
+ const { privacyMode } = useSelector(getPreferences);
const selectedAccount = useSelector(getSelectedAccount);
const shouldHideZeroBalanceTokens = useSelector(
getShouldHideZeroBalanceTokens,
@@ -110,7 +112,7 @@ export const AggregatedPercentageOverview = () => {
let color = TextColor.textDefault;
- if (isValidAmount(amountChange)) {
+ if (!privacyMode && isValidAmount(amountChange)) {
if ((amountChange as number) === 0) {
color = TextColor.textDefault;
} else if ((amountChange as number) > 0) {
@@ -118,26 +120,33 @@ export const AggregatedPercentageOverview = () => {
} else {
color = TextColor.errorDefault;
}
+ } else {
+ color = TextColor.textAlternative;
}
+
return (
-
{formattedAmountChange}
-
-
+
{formattedPercentChange}
-
+
);
};
diff --git a/ui/components/app/wallet-overview/coin-overview.tsx b/ui/components/app/wallet-overview/coin-overview.tsx
index 2de787ef23c0..93d9e1061428 100644
--- a/ui/components/app/wallet-overview/coin-overview.tsx
+++ b/ui/components/app/wallet-overview/coin-overview.tsx
@@ -28,6 +28,7 @@ import {
JustifyContent,
TextAlign,
TextVariant,
+ IconColor,
} from '../../../helpers/constants/design-system';
///: BEGIN:ONLY_INCLUDE_IF(build-main,build-beta,build-flask)
import { getPortfolioUrl } from '../../../helpers/utils/portfolio';
@@ -61,7 +62,10 @@ import Spinner from '../../ui/spinner';
import { PercentageAndAmountChange } from '../../multichain/token-list-item/price/percentage-and-amount-change/percentage-and-amount-change';
import { getMultichainIsEvm } from '../../../selectors/multichain';
import { useAccountTotalFiatBalance } from '../../../hooks/useAccountTotalFiatBalance';
-import { setAggregatedBalancePopoverShown } from '../../../store/actions';
+import {
+ setAggregatedBalancePopoverShown,
+ setPrivacyMode,
+} from '../../../store/actions';
import { useTheme } from '../../../hooks/useTheme';
import { getSpecificSettingsRoute } from '../../../helpers/utils/settings-search';
import { useI18nContext } from '../../../hooks/useI18nContext';
@@ -128,7 +132,8 @@ export const CoinOverview = ({
const shouldShowPopover = useSelector(getShouldShowAggregatedBalancePopover);
const isTestnet = useSelector(getIsTestnet);
- const { showFiatInTestnets } = useSelector(getPreferences);
+ const { showFiatInTestnets, privacyMode, showNativeTokenAsMainBalance } =
+ useSelector(getPreferences);
const selectedAccount = useSelector(getSelectedAccount);
const shouldHideZeroBalanceTokens = useSelector(
@@ -139,8 +144,6 @@ export const CoinOverview = ({
shouldHideZeroBalanceTokens,
);
- const { showNativeTokenAsMainBalance } = useSelector(getPreferences);
-
const isEvm = useSelector(getMultichainIsEvm);
const isNotAggregatedFiatBalance =
showNativeTokenAsMainBalance || isTestnet || !isEvm;
@@ -163,6 +166,10 @@ export const CoinOverview = ({
dispatch(setAggregatedBalancePopoverShown());
};
+ const handleSensitiveToggle = () => {
+ dispatch(setPrivacyMode(!privacyMode));
+ };
+
const [referenceElement, setReferenceElement] =
useState(null);
const setBoxRef = (ref: HTMLSpanElement | null) => {
@@ -253,26 +260,39 @@ export const CoinOverview = ({
ref={setBoxRef}
>
{balanceToDisplay ? (
-
+ <>
+
+
+ >
) : (
)}
diff --git a/ui/components/app/wallet-overview/index.scss b/ui/components/app/wallet-overview/index.scss
index 318c26501097..47dc40200e69 100644
--- a/ui/components/app/wallet-overview/index.scss
+++ b/ui/components/app/wallet-overview/index.scss
@@ -78,7 +78,8 @@
display: flex;
max-width: inherit;
justify-content: center;
- flex-wrap: wrap;
+ align-items: center;
+ flex-wrap: nowrap;
}
&__primary-balance {
@@ -142,7 +143,8 @@
display: flex;
max-width: inherit;
justify-content: center;
- flex-wrap: wrap;
+ align-items: center;
+ flex-wrap: nowrap;
}
&__primary-balance {
diff --git a/ui/components/component-library/icon/icon.types.ts b/ui/components/component-library/icon/icon.types.ts
index 9c87851d0b65..3afd17ef983b 100644
--- a/ui/components/component-library/icon/icon.types.ts
+++ b/ui/components/component-library/icon/icon.types.ts
@@ -44,6 +44,7 @@ export enum IconName {
Book = 'book',
Bookmark = 'bookmark',
Bridge = 'bridge',
+ Collapse = 'collapse',
Calculator = 'calculator',
CardPos = 'card-pos',
CardToken = 'card-token',
diff --git a/ui/components/component-library/index.ts b/ui/components/component-library/index.ts
index 861fb80bcf2c..634af093a41b 100644
--- a/ui/components/component-library/index.ts
+++ b/ui/components/component-library/index.ts
@@ -69,6 +69,8 @@ export { TagUrl } from './tag-url';
export type { TagUrlProps } from './tag-url';
export { Text, ValidTag, TextDirection, InvisibleCharacter } from './text';
export type { TextProps } from './text';
+export { SensitiveText, SensitiveTextLength } from './sensitive-text';
+export type { SensitiveTextProps } from './sensitive-text';
export { Input, InputType } from './input';
export type { InputProps } from './input';
export { TextField, TextFieldType, TextFieldSize } from './text-field';
diff --git a/ui/components/component-library/sensitive-text/README.mdx b/ui/components/component-library/sensitive-text/README.mdx
new file mode 100644
index 000000000000..9e950381e6f3
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/README.mdx
@@ -0,0 +1,81 @@
+import { Controls, Canvas } from '@storybook/blocks';
+
+import * as SensitiveTextStories from './sensitive-text.stories';
+
+# SensitiveText
+
+SensitiveText is a component that extends the Text component to handle sensitive information. It provides the ability to hide or show the text content, replacing it with dots when hidden.
+
+
+
+## Props
+
+The `SensitiveText` component extends the `Text` component. See the `Text` component for an extended list of props.
+
+
+
+### Children
+
+The text content to be displayed or hidden.
+
+
+
+```jsx
+import { SensitiveText } from '../../component-library';
+
+
+ Sensitive Information
+
+```
+
+
+### IsHidden
+
+Use the `isHidden` prop to determine whether the text should be hidden or visible. When `isHidden` is `true`, the component will display dots instead of the actual text.
+
+
+
+```jsx
+import { SensitiveText } from '../../component-library';
+
+
+ Sensitive Information
+
+```
+
+### Length
+
+Use the `length` prop to determine the length of the hidden text (number of dots). Can be a predefined `SensitiveTextLength` or a custom string number.
+
+The following predefined length options are available:
+
+- `SensitiveTextLength.Short`: `6`
+- `SensitiveTextLength.Medium`: `9`
+- `SensitiveTextLength.Long`: `12`
+- `SensitiveTextLength.ExtraLong`: `20`
+
+- The number of dots displayed is determined by the `length` prop.
+- If an invalid `length` is provided, the component will fall back to `SensitiveTextLength.Short` and log a warning.
+- Custom length values can be provided as strings, e.g. `15`.
+
+
+
+```jsx
+import { SensitiveText, SensitiveTextLength } from '../../component-library';
+
+
+ Length "short" (6 characters)
+
+
+ Length "medium" (9 characters)
+
+
+ Length "long" (12 characters)
+
+
+ Length "extra long" (20 characters)
+
+
+ Length "15" (15 characters)
+
+```
diff --git a/ui/components/component-library/sensitive-text/__snapshots__/sensitive-text.test.tsx.snap b/ui/components/component-library/sensitive-text/__snapshots__/sensitive-text.test.tsx.snap
new file mode 100644
index 000000000000..6844feb1783e
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/__snapshots__/sensitive-text.test.tsx.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`SensitiveText should render correctly 1`] = `
+
+
+ Sensitive Information
+
+
+`;
diff --git a/ui/components/component-library/sensitive-text/index.ts b/ui/components/component-library/sensitive-text/index.ts
new file mode 100644
index 000000000000..ff89896fd03b
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/index.ts
@@ -0,0 +1,3 @@
+export { SensitiveText } from './sensitive-text';
+export { SensitiveTextLength } from './sensitive-text.types';
+export type { SensitiveTextProps } from './sensitive-text.types';
diff --git a/ui/components/component-library/sensitive-text/sensitive-text.stories.tsx b/ui/components/component-library/sensitive-text/sensitive-text.stories.tsx
new file mode 100644
index 000000000000..142def9118b5
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/sensitive-text.stories.tsx
@@ -0,0 +1,74 @@
+import type { Meta, StoryObj } from '@storybook/react';
+import React from 'react';
+import { SensitiveText } from '.';
+import { SensitiveTextLength } from './sensitive-text.types';
+import README from './README.mdx';
+import { Box } from '../box';
+import {
+ Display,
+ FlexDirection,
+} from '../../../helpers/constants/design-system';
+
+const meta: Meta = {
+ title: 'Components/ComponentLibrary/SensitiveText',
+ component: SensitiveText,
+ parameters: {
+ docs: {
+ page: README,
+ },
+ },
+ args: {
+ children: 'Sensitive information',
+ isHidden: false,
+ length: SensitiveTextLength.Short,
+ },
+} as Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const DefaultStory: Story = {};
+DefaultStory.storyName = 'Default';
+
+export const Children: Story = {
+ args: {
+ children: 'Sensitive information',
+ },
+ render: (args) => (
+
+ ),
+};
+
+export const IsHidden: Story = {
+ args: {
+ isHidden: true,
+ },
+ render: (args) => (
+
+ ),
+};
+
+export const Length: Story = {
+ args: {
+ isHidden: true,
+ },
+ render: (args) => (
+
+
+ Length "short" (6 characters)
+
+
+ Length "medium" (9 characters)
+
+
+ Length "long" (12 characters)
+
+
+ Length "extra long" (20 characters)
+
+
+ Length "15" (15 characters)
+
+
+ ),
+};
diff --git a/ui/components/component-library/sensitive-text/sensitive-text.test.tsx b/ui/components/component-library/sensitive-text/sensitive-text.test.tsx
new file mode 100644
index 000000000000..a4be911ea78d
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/sensitive-text.test.tsx
@@ -0,0 +1,81 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { SensitiveText } from './sensitive-text';
+import { SensitiveTextLength } from './sensitive-text.types';
+
+describe('SensitiveText', () => {
+ const testProps = {
+ isHidden: false,
+ length: SensitiveTextLength.Short,
+ children: 'Sensitive Information',
+ };
+
+ it('should render correctly', () => {
+ const { container } = render();
+ expect(container).toMatchSnapshot();
+ });
+
+ it('should display the text when isHidden is false', () => {
+ render();
+ expect(screen.getByText('Sensitive Information')).toBeInTheDocument();
+ });
+
+ it('should hide the text when isHidden is true', () => {
+ render();
+ expect(screen.queryByText('Sensitive Information')).not.toBeInTheDocument();
+ expect(screen.getByText('••••••')).toBeInTheDocument();
+ });
+
+ it('should render the correct number of bullets for different lengths', () => {
+ const lengths = [
+ SensitiveTextLength.Short,
+ SensitiveTextLength.Medium,
+ SensitiveTextLength.Long,
+ SensitiveTextLength.ExtraLong,
+ ];
+
+ lengths.forEach((length) => {
+ render();
+ expect(screen.getByText('•'.repeat(Number(length)))).toBeInTheDocument();
+ });
+ });
+
+ it('should handle all predefined SensitiveTextLength values', () => {
+ Object.entries(SensitiveTextLength).forEach(([_, value]) => {
+ render();
+ expect(screen.getByText('•'.repeat(Number(value)))).toBeInTheDocument();
+ });
+ });
+
+ it('should handle custom length as a string', () => {
+ render();
+ expect(screen.getByText('•'.repeat(15))).toBeInTheDocument();
+ });
+
+ it('should fall back to Short length for invalid custom length', () => {
+ render();
+ expect(
+ screen.getByText('•'.repeat(Number(SensitiveTextLength.Short))),
+ ).toBeInTheDocument();
+ });
+
+ it('should log a warning for invalid custom length', () => {
+ const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();
+ render();
+ expect(consoleSpy).toHaveBeenCalledWith(
+ 'Invalid length provided: abc. Falling back to Short.',
+ );
+ consoleSpy.mockRestore();
+ });
+
+ it('should apply additional props to the Text component', () => {
+ render();
+ expect(screen.getByTestId('sensitive-text')).toBeInTheDocument();
+ });
+
+ it('should forward ref to the Text component', () => {
+ const ref = React.createRef();
+ render();
+ expect(ref.current).toBeInstanceOf(HTMLParagraphElement);
+ });
+});
diff --git a/ui/components/component-library/sensitive-text/sensitive-text.tsx b/ui/components/component-library/sensitive-text/sensitive-text.tsx
new file mode 100644
index 000000000000..ddabda784fe1
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/sensitive-text.tsx
@@ -0,0 +1,48 @@
+import React, { useMemo } from 'react';
+import { Text } from '../text';
+import {
+ SensitiveTextProps,
+ SensitiveTextLength,
+} from './sensitive-text.types';
+
+export const SensitiveText = React.forwardRef<
+ HTMLParagraphElement,
+ SensitiveTextProps
+>((props, ref) => {
+ const {
+ isHidden = false,
+ length = SensitiveTextLength.Short,
+ children = '',
+ ...restProps
+ } = props;
+
+ const getFallbackLength = useMemo(
+ () => (len: string) => {
+ const numLength = Number(len);
+ return Number.isNaN(numLength) ? 0 : numLength;
+ },
+ [],
+ );
+
+ const isValidCustomLength = (value: string): boolean => {
+ const num = Number(value);
+ return !Number.isNaN(num) && num > 0;
+ };
+
+ let adjustedLength = length;
+ if (!(length in SensitiveTextLength) && !isValidCustomLength(length)) {
+ console.warn(`Invalid length provided: ${length}. Falling back to Short.`);
+ adjustedLength = SensitiveTextLength.Short;
+ }
+
+ const fallback = useMemo(
+ () => '•'.repeat(getFallbackLength(adjustedLength)),
+ [length, getFallbackLength],
+ );
+
+ return (
+
+ {isHidden ? fallback : children}
+
+ );
+});
diff --git a/ui/components/component-library/sensitive-text/sensitive-text.types.ts b/ui/components/component-library/sensitive-text/sensitive-text.types.ts
new file mode 100644
index 000000000000..1ea8270d377f
--- /dev/null
+++ b/ui/components/component-library/sensitive-text/sensitive-text.types.ts
@@ -0,0 +1,44 @@
+import type { TextProps } from '../text/text.types';
+
+/**
+ * SensitiveText length options.
+ */
+export const SensitiveTextLength = {
+ Short: '6',
+ Medium: '9',
+ Long: '12',
+ ExtraLong: '20',
+} as const;
+
+/**
+ * Type for SensitiveTextLength values.
+ */
+export type SensitiveTextLengthType =
+ (typeof SensitiveTextLength)[keyof typeof SensitiveTextLength];
+/**
+ * Type for custom length values.
+ */
+export type CustomLength = string;
+
+export type SensitiveTextProps = Omit<
+ TextProps,
+ 'children'
+> & {
+ /**
+ * Boolean to determine whether the text should be hidden or visible.
+ *
+ * @default false
+ */
+ isHidden?: boolean;
+ /**
+ * Determines the length of the hidden text (number of asterisks).
+ * Can be a predefined SensitiveTextLength or a custom string number.
+ *
+ * @default SensitiveTextLength.Short
+ */
+ length?: SensitiveTextLengthType | CustomLength;
+ /**
+ * The text content to be displayed or hidden.
+ */
+ children?: React.ReactNode;
+};
diff --git a/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx b/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx
index 06fe1336ca7e..e76dabc7add7 100644
--- a/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx
+++ b/ui/components/institutional/interactive-replacement-token-modal/interactive-replacement-token-modal.tsx
@@ -4,7 +4,7 @@ import { ICustodianType } from '@metamask-institutional/types';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import { hideModal } from '../../../store/actions';
-import { getSelectedInternalAccount } from '../../../selectors/selectors';
+import { getSelectedInternalAccount } from '../../../selectors/accounts';
import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils';
import {
Box,
diff --git a/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx b/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx
index 10dc049b8678..7c1d0f60488f 100644
--- a/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx
+++ b/ui/components/institutional/interactive-replacement-token-notification/interactive-replacement-token-notification.tsx
@@ -56,6 +56,7 @@ const InteractiveReplacementTokenNotification: React.FC<
interactiveReplacementToken &&
Boolean(Object.keys(interactiveReplacementToken).length);
+ // @ts-expect-error keyring type is wrong maybe?
if (!/^Custody/u.test(keyring.type) || !hasInteractiveReplacementToken) {
setShowNotification(false);
return;
@@ -66,6 +67,7 @@ const InteractiveReplacementTokenNotification: React.FC<
)) as unknown as string;
const custodyAccountDetails = await dispatch(
mmiActions.getAllCustodianAccountsWithToken(
+ // @ts-expect-error keyring type is wrong maybe?
keyring.type.split(' - ')[1],
token,
),
@@ -105,6 +107,7 @@ const InteractiveReplacementTokenNotification: React.FC<
interactiveReplacementToken?.oldRefreshToken,
isUnlocked,
dispatch,
+ // @ts-expect-error keyring type is wrong maybe?
keyring.type,
interactiveReplacementToken,
mmiActions,
diff --git a/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap b/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap
index 51f6f2e905f9..e320bd1de0e3 100644
--- a/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap
+++ b/ui/components/multichain/account-list-item/__snapshots__/account-list-item.test.js.snap
@@ -242,6 +242,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
>
$100,000.00
@@ -538,6 +539,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
>
0.006
@@ -581,6 +583,7 @@ exports[`AccountListItem renders AccountListItem component and shows account nam
>
0.006
diff --git a/ui/components/multichain/account-list-item/account-list-item.js b/ui/components/multichain/account-list-item/account-list-item.js
index 517639b1c86e..143c4d142a16 100644
--- a/ui/components/multichain/account-list-item/account-list-item.js
+++ b/ui/components/multichain/account-list-item/account-list-item.js
@@ -86,6 +86,8 @@ const AccountListItem = ({
isActive = false,
startAccessory,
onActionClick,
+ shouldScrollToWhenSelected = true,
+ privacyMode = false,
}) => {
const t = useI18nContext();
const [accountOptionsMenuOpen, setAccountOptionsMenuOpen] = useState(false);
@@ -128,10 +130,10 @@ const AccountListItem = ({
// scroll the item into view
const itemRef = useRef(null);
useEffect(() => {
- if (selected) {
+ if (selected && shouldScrollToWhenSelected) {
itemRef.current?.scrollIntoView?.();
}
- }, [itemRef, selected]);
+ }, [itemRef, selected, shouldScrollToWhenSelected]);
const trackEvent = useContext(MetaMetricsContext);
const primaryTokenImage = useMultichainSelector(
@@ -312,6 +314,7 @@ const AccountListItem = ({
type={PRIMARY}
showFiat={showFiat}
data-testid="first-currency-display"
+ privacyMode={privacyMode}
/>
@@ -359,6 +362,7 @@ const AccountListItem = ({
type={SECONDARY}
showNative
data-testid="second-currency-display"
+ privacyMode={privacyMode}
/>
@@ -502,6 +506,14 @@ AccountListItem.propTypes = {
* Represents start accessory
*/
startAccessory: PropTypes.node,
+ /**
+ * Determines if list item should be scrolled to when selected
+ */
+ shouldScrollToWhenSelected: PropTypes.bool,
+ /**
+ * Determines if list balance should be obfuscated
+ */
+ privacyMode: PropTypes.bool,
};
AccountListItem.displayName = 'AccountListItem';
diff --git a/ui/components/multichain/account-list-menu/account-list-menu.tsx b/ui/components/multichain/account-list-menu/account-list-menu.tsx
index 19d313aedf54..cfb49d246ca6 100644
--- a/ui/components/multichain/account-list-menu/account-list-menu.tsx
+++ b/ui/components/multichain/account-list-menu/account-list-menu.tsx
@@ -188,6 +188,7 @@ export const mergeAccounts = (
type AccountListMenuProps = {
onClose: () => void;
+ privacyMode?: boolean;
showAccountCreation?: boolean;
accountListItemProps?: object;
allowedAccountTypes?: KeyringAccountType[];
@@ -195,6 +196,7 @@ type AccountListMenuProps = {
export const AccountListMenu = ({
onClose,
+ privacyMode = false,
showAccountCreation = true,
accountListItemProps,
allowedAccountTypes = [
@@ -455,6 +457,7 @@ export const AccountListMenu = ({
{
trackEvent({
category: MetaMetricsEventCategory.Navigation,
@@ -643,6 +646,7 @@ export const AccountListMenu = ({
isHidden={Boolean(account.hidden)}
currentTabOrigin={currentTabOrigin}
isActive={Boolean(account.active)}
+ privacyMode={privacyMode}
{...accountListItemProps}
/>
diff --git a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap
index d22597edd89f..247f7aeb5c78 100644
--- a/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap
+++ b/ui/components/multichain/app-header/__snapshots__/app-header.test.js.snap
@@ -616,6 +616,7 @@ exports[`App Header unlocked state matches snapshot: unlocked 1`] = `
>
1
diff --git a/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap b/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap
index 9c0bd9c49482..a0c808186082 100644
--- a/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap
+++ b/ui/components/multichain/asset-picker-amount/asset-balance/__snapshots__/asset-balance-text.test.tsx.snap
@@ -8,6 +8,7 @@ exports[`AssetBalanceText matches snapshot 1`] = `
>
prefix-fiat value
diff --git a/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap b/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap
index d53c8e7d8d8a..b4a4836db2d6 100644
--- a/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap
+++ b/ui/components/multichain/connect-accounts-modal/__snapshots__/connect-accounts-modal.test.tsx.snap
@@ -358,6 +358,7 @@ exports[`Connect More Accounts Modal should render correctly 1`] = `
>
0
@@ -401,6 +402,7 @@ exports[`Connect More Accounts Modal should render correctly 1`] = `
>
0
diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx
index 509a4aa60a2a..34ec98e671b9 100644
--- a/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx
+++ b/ui/components/multichain/funding-method-modal/funding-method-modal.test.tsx
@@ -57,7 +57,7 @@ describe('FundingMethodModal', () => {
expect(queryByTestId('funding-method-modal')).toBeNull();
});
- it('should call openBuyCryptoInPdapp when the Buy Crypto item is clicked', () => {
+ it('should call openBuyCryptoInPdapp when the Token Marketplace item is clicked', () => {
const { getByText } = renderWithProvider(
{
store,
);
- fireEvent.click(getByText('Buy crypto'));
+ fireEvent.click(getByText('Token marketplace'));
expect(openBuyCryptoInPdapp).toHaveBeenCalled();
});
diff --git a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx
index 47d6ed22c2e8..baa0e234a32a 100644
--- a/ui/components/multichain/funding-method-modal/funding-method-modal.tsx
+++ b/ui/components/multichain/funding-method-modal/funding-method-modal.tsx
@@ -115,8 +115,8 @@ export const FundingMethodModal: React.FC = ({
{
@@ -21,6 +22,7 @@ exports[`CurrencyDisplay Component should render text with a className 1`] = `
>
$123.45
@@ -36,6 +38,7 @@ exports[`CurrencyDisplay Component should render text with a prefix 1`] = `
>
-
$123.45
diff --git a/ui/components/ui/currency-display/currency-display.component.js b/ui/components/ui/currency-display/currency-display.component.js
index ca9322661d79..7e2569ffaee3 100644
--- a/ui/components/ui/currency-display/currency-display.component.js
+++ b/ui/components/ui/currency-display/currency-display.component.js
@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
import classnames from 'classnames';
import { useCurrencyDisplay } from '../../../hooks/useCurrencyDisplay';
import { EtherDenomination } from '../../../../shared/constants/common';
-import { Text, Box } from '../../component-library';
+import { SensitiveText, Box } from '../../component-library';
import {
AlignItems,
Display,
@@ -33,6 +33,7 @@ export default function CurrencyDisplay({
textProps = {},
suffixProps = {},
isAggregatedFiatOverviewBalance = false,
+ privacyMode = false,
...props
}) {
const [title, parts] = useCurrencyDisplay(value, {
@@ -68,26 +69,33 @@ export default function CurrencyDisplay({
{prefixComponent}
) : null}
-
{parts.prefix}
{parts.value}
-
+
{parts.suffix ? (
-
{parts.suffix}
-
+
) : null}
);
@@ -115,6 +123,7 @@ const CurrencyDisplayPropTypes = {
textProps: PropTypes.object,
suffixProps: PropTypes.object,
isAggregatedFiatOverviewBalance: PropTypes.bool,
+ privacyMode: PropTypes.bool,
};
CurrencyDisplay.propTypes = CurrencyDisplayPropTypes;
diff --git a/ui/components/ui/definition-list/definition-list.js b/ui/components/ui/definition-list/definition-list.js
index 84a23325b37a..84d3f48135ab 100644
--- a/ui/components/ui/definition-list/definition-list.js
+++ b/ui/components/ui/definition-list/definition-list.js
@@ -32,7 +32,7 @@ export default function DefinitionList({
{Object.entries(dictionary).map(([term, definition]) => (
) : (
) : (
@@ -292,4 +297,5 @@ SenderToRecipient.propTypes = {
onSenderClick: PropTypes.func,
warnUserOnAccountMismatch: PropTypes.bool,
recipientIsOwnedAccount: PropTypes.bool,
+ chainId: PropTypes.string,
};
diff --git a/ui/components/ui/token-currency-display/token-currency-display.stories.tsx b/ui/components/ui/token-currency-display/token-currency-display.stories.tsx
index 7cf850c42c84..932d54210b84 100644
--- a/ui/components/ui/token-currency-display/token-currency-display.stories.tsx
+++ b/ui/components/ui/token-currency-display/token-currency-display.stories.tsx
@@ -1,9 +1,8 @@
import React from 'react';
-import { Meta, Story } from '@storybook/react';
+import type { Meta, StoryObj } from '@storybook/react';
import TokenCurrencyDisplay from './token-currency-display.component';
-import { TokenCurrencyDisplayProps } from './token-currency-display.types';
-export default {
+const meta: Meta
= {
title: 'Components/UI/TokenCurrencyDisplay',
component: TokenCurrencyDisplay,
argTypes: {
@@ -12,14 +11,15 @@ export default {
token: { control: 'object' },
prefix: { control: 'text' },
},
-} as Meta;
+ args: {
+ className: '',
+ transactionData: '0x123',
+ token: { symbol: 'ETH' },
+ prefix: '',
+ },
+};
-const Template: Story = (args) => ;
+export default meta;
+type Story = StoryObj;
-export const Default = Template.bind({});
-Default.args = {
- className: '',
- transactionData: '0x123',
- token: { symbol: 'ETH' },
- prefix: '',
-};
+export const Default: Story = {};
diff --git a/ui/components/ui/truncated-definition-list/truncated-definition-list.js b/ui/components/ui/truncated-definition-list/truncated-definition-list.js
index ae1782979866..2db9784dad8e 100644
--- a/ui/components/ui/truncated-definition-list/truncated-definition-list.js
+++ b/ui/components/ui/truncated-definition-list/truncated-definition-list.js
@@ -5,7 +5,6 @@ import { BorderColor, Size } from '../../../helpers/constants/design-system';
import Box from '../box';
import Button from '../button';
import DefinitionList from '../definition-list/definition-list';
-import Popover from '../popover';
import { useI18nContext } from '../../../hooks/useI18nContext';
export default function TruncatedDefinitionList({
@@ -13,7 +12,6 @@ export default function TruncatedDefinitionList({
tooltips,
warnings,
prefaceKeys,
- title,
}) {
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
const t = useI18nContext();
@@ -33,55 +31,27 @@ export default function TruncatedDefinitionList({
type="link"
onClick={() => setIsPopoverOpen(true)}
>
- {t(process.env.CHAIN_PERMISSIONS ? 'seeDetails' : 'viewAllDetails')}
+ {t('seeDetails')}
);
- const renderPopover = () =>
- isPopoverOpen && (
- setIsPopoverOpen(false)}
- footer={
-
- }
- >
-
- {renderDefinitionList(true)}
-
-
- );
-
const renderContent = () => {
- if (process.env.CHAIN_PERMISSIONS) {
- return isPopoverOpen ? (
- renderDefinitionList(true)
- ) : (
- <>
- {renderDefinitionList(false)}
- {renderButton()}
- >
- );
- }
- return (
+ return isPopoverOpen ? (
+ renderDefinitionList(true)
+ ) : (
<>
{renderDefinitionList(false)}
{renderButton()}
- {renderPopover()}
>
);
};
return (
(
bridgeAction: BridgeUserAction | BridgeBackgroundAction,
- args?: T[],
+ args?: T,
) => {
return async (dispatch: MetaMaskReduxDispatch) => {
- await submitRequestToBackground(bridgeAction, args);
+ await submitRequestToBackground(bridgeAction, [args]);
await forceUpdateMetamaskState(dispatch);
};
};
@@ -53,20 +53,29 @@ export const setBridgeFeatureFlags = () => {
export const setFromChain = (chainId: Hex) => {
return async (dispatch: MetaMaskReduxDispatch) => {
dispatch(
- callBridgeControllerMethod(BridgeUserAction.SELECT_SRC_NETWORK, [
+ callBridgeControllerMethod(
+ BridgeUserAction.SELECT_SRC_NETWORK,
chainId,
- ]),
+ ),
);
};
};
export const setToChain = (chainId: Hex) => {
return async (dispatch: MetaMaskReduxDispatch) => {
- dispatch(setToChainId_(chainId));
dispatch(
- callBridgeControllerMethod(BridgeUserAction.SELECT_DEST_NETWORK, [
+ callBridgeControllerMethod(
+ BridgeUserAction.SELECT_DEST_NETWORK,
chainId,
- ]),
+ ),
+ );
+ };
+};
+
+export const updateQuoteRequestParams = (params: Partial) => {
+ return async (dispatch: MetaMaskReduxDispatch) => {
+ await dispatch(
+ callBridgeControllerMethod(BridgeUserAction.UPDATE_QUOTE_PARAMS, params),
);
};
};
diff --git a/ui/ducks/bridge/bridge.test.ts b/ui/ducks/bridge/bridge.test.ts
index f4a566c233b5..6b85565c6143 100644
--- a/ui/ducks/bridge/bridge.test.ts
+++ b/ui/ducks/bridge/bridge.test.ts
@@ -1,5 +1,6 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
+import { zeroAddress } from 'ethereumjs-util';
import { createBridgeMockStore } from '../../../test/jest/mock-store';
import { CHAIN_IDS } from '../../../shared/constants/network';
import { setBackgroundConnection } from '../../store/background-connection';
@@ -18,7 +19,8 @@ import {
setToToken,
setFromChain,
resetInputFields,
- switchToAndFromTokens,
+ setToChainId,
+ updateQuoteRequestParams,
} from './actions';
const middleware = [thunk];
@@ -31,11 +33,25 @@ describe('Ducks - Bridge', () => {
store.clearActions();
});
- describe('setToChain', () => {
- it('calls the "bridge/setToChainId" action and the selectDestNetwork background action', () => {
+ describe('setToChainId', () => {
+ it('calls the "bridge/setToChainId" action', () => {
const state = store.getState().bridge;
const actionPayload = CHAIN_IDS.OPTIMISM;
+ store.dispatch(setToChainId(actionPayload as never) as never);
+
+ // Check redux state
+ const actions = store.getActions();
+ expect(actions[0].type).toStrictEqual('bridge/setToChainId');
+ const newState = bridgeReducer(state, actions[0]);
+ expect(newState.toChainId).toStrictEqual(actionPayload);
+ });
+ });
+
+ describe('setToChain', () => {
+ it('calls the selectDestNetwork background action', () => {
+ const actionPayload = CHAIN_IDS.OPTIMISM;
+
const mockSelectDestNetwork = jest.fn().mockReturnValue({});
setBackgroundConnection({
[BridgeUserAction.SELECT_DEST_NETWORK]: mockSelectDestNetwork,
@@ -43,11 +59,6 @@ describe('Ducks - Bridge', () => {
store.dispatch(setToChain(actionPayload as never) as never);
- // Check redux state
- const actions = store.getActions();
- expect(actions[0].type).toStrictEqual('bridge/setToChainId');
- const newState = bridgeReducer(state, actions[0]);
- expect(newState.toChainId).toStrictEqual(actionPayload);
// Check background state
expect(mockSelectDestNetwork).toHaveBeenCalledTimes(1);
expect(mockSelectDestNetwork).toHaveBeenCalledWith(
@@ -61,7 +72,7 @@ describe('Ducks - Bridge', () => {
it('calls the "bridge/setFromToken" action', () => {
const state = store.getState().bridge;
const actionPayload = { symbol: 'SYMBOL', address: '0x13341432' };
- store.dispatch(setFromToken(actionPayload));
+ store.dispatch(setFromToken(actionPayload as never) as never);
const actions = store.getActions();
expect(actions[0].type).toStrictEqual('bridge/setFromToken');
const newState = bridgeReducer(state, actions[0]);
@@ -73,7 +84,8 @@ describe('Ducks - Bridge', () => {
it('calls the "bridge/setToToken" action', () => {
const state = store.getState().bridge;
const actionPayload = { symbol: 'SYMBOL', address: '0x13341431' };
- store.dispatch(setToToken(actionPayload));
+
+ store.dispatch(setToToken(actionPayload as never) as never);
const actions = store.getActions();
expect(actions[0].type).toStrictEqual('bridge/setToToken');
const newState = bridgeReducer(state, actions[0]);
@@ -85,7 +97,8 @@ describe('Ducks - Bridge', () => {
it('calls the "bridge/setFromTokenInputValue" action', () => {
const state = store.getState().bridge;
const actionPayload = '10';
- store.dispatch(setFromTokenInputValue(actionPayload));
+
+ store.dispatch(setFromTokenInputValue(actionPayload as never) as never);
const actions = store.getActions();
expect(actions[0].type).toStrictEqual('bridge/setFromTokenInputValue');
const newState = bridgeReducer(state, actions[0]);
@@ -137,31 +150,30 @@ describe('Ducks - Bridge', () => {
});
});
- describe('switchToAndFromTokens', () => {
- it('switches to and from input values', async () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const bridgeStore = configureMockStore(middleware)(
- createBridgeMockStore(
- {},
- {
- toChainId: CHAIN_IDS.MAINNET,
- fromToken: { symbol: 'WETH', address: '0x13341432' },
- toToken: { symbol: 'USDC', address: '0x13341431' },
- fromTokenInputValue: '10',
- },
- ),
+ describe('updateQuoteRequestParams', () => {
+ it('dispatches quote params to the bridge controller', () => {
+ const mockUpdateParams = jest.fn();
+ setBackgroundConnection({
+ [BridgeUserAction.UPDATE_QUOTE_PARAMS]: mockUpdateParams,
+ } as never);
+
+ store.dispatch(
+ updateQuoteRequestParams({
+ srcChainId: 1,
+ srcTokenAddress: zeroAddress(),
+ destTokenAddress: undefined,
+ }) as never,
+ );
+
+ expect(mockUpdateParams).toHaveBeenCalledTimes(1);
+ expect(mockUpdateParams).toHaveBeenCalledWith(
+ {
+ srcChainId: 1,
+ srcTokenAddress: zeroAddress(),
+ destTokenAddress: undefined,
+ },
+ expect.anything(),
);
- const state = bridgeStore.getState().bridge;
- bridgeStore.dispatch(switchToAndFromTokens(CHAIN_IDS.POLYGON));
- const actions = bridgeStore.getActions();
- expect(actions[0].type).toStrictEqual('bridge/switchToAndFromTokens');
- const newState = bridgeReducer(state, actions[0]);
- expect(newState).toStrictEqual({
- toChainId: CHAIN_IDS.POLYGON,
- fromToken: { symbol: 'USDC', address: '0x13341431' },
- toToken: { symbol: 'WETH', address: '0x13341432' },
- fromTokenInputValue: null,
- });
});
});
});
diff --git a/ui/ducks/bridge/bridge.ts b/ui/ducks/bridge/bridge.ts
index 9ec744d9e953..c75030c7591d 100644
--- a/ui/ducks/bridge/bridge.ts
+++ b/ui/ducks/bridge/bridge.ts
@@ -39,12 +39,6 @@ const bridgeSlice = createSlice({
resetInputFields: () => ({
...initialState,
}),
- switchToAndFromTokens: (state, { payload }) => ({
- toChainId: payload,
- fromToken: state.toToken,
- toToken: state.fromToken,
- fromTokenInputValue: null,
- }),
},
});
diff --git a/ui/ducks/bridge/selectors.test.ts b/ui/ducks/bridge/selectors.test.ts
index cf27790aa943..6be67515e6e4 100644
--- a/ui/ducks/bridge/selectors.test.ts
+++ b/ui/ducks/bridge/selectors.test.ts
@@ -30,7 +30,7 @@ describe('Bridge selectors', () => {
{ srcNetworkAllowlist: [CHAIN_IDS.ARBITRUM] },
{ toChainId: '0xe708' },
{},
- { ...mockNetworkState(FEATURED_RPCS[0]) },
+ { ...mockNetworkState(FEATURED_RPCS[1]) },
);
const result = getFromChain(state as never);
@@ -89,7 +89,7 @@ describe('Bridge selectors', () => {
);
const result = getAllBridgeableNetworks(state as never);
- expect(result).toHaveLength(7);
+ expect(result).toHaveLength(8);
expect(result[0]).toStrictEqual(
expect.objectContaining({ chainId: FEATURED_RPCS[0].chainId }),
);
@@ -190,21 +190,19 @@ describe('Bridge selectors', () => {
},
{},
{},
- mockNetworkState(...FEATURED_RPCS, {
- chainId: CHAIN_IDS.LINEA_MAINNET,
- }),
+ mockNetworkState(...FEATURED_RPCS),
);
const result = getToChains(state as never);
expect(result).toHaveLength(3);
expect(result[0]).toStrictEqual(
- expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }),
+ expect.objectContaining({ chainId: CHAIN_IDS.ARBITRUM }),
);
expect(result[1]).toStrictEqual(
- expect.objectContaining({ chainId: CHAIN_IDS.POLYGON }),
+ expect.objectContaining({ chainId: CHAIN_IDS.OPTIMISM }),
);
expect(result[2]).toStrictEqual(
- expect.objectContaining({ chainId: CHAIN_IDS.LINEA_MAINNET }),
+ expect.objectContaining({ chainId: CHAIN_IDS.POLYGON }),
);
});
@@ -297,7 +295,9 @@ describe('Bridge selectors', () => {
{
...mockNetworkState(
...Object.values(BUILT_IN_NETWORKS),
- ...FEATURED_RPCS,
+ ...FEATURED_RPCS.filter(
+ (network) => network.chainId !== CHAIN_IDS.LINEA_MAINNET, // Linea mainnet is both a built in network, as well as featured RPC
+ ),
),
useExternalServices: true,
},
diff --git a/ui/ducks/bridge/selectors.ts b/ui/ducks/bridge/selectors.ts
index 8cd56928fc66..568d62e7a2d4 100644
--- a/ui/ducks/bridge/selectors.ts
+++ b/ui/ducks/bridge/selectors.ts
@@ -8,7 +8,7 @@ import {
getIsBridgeEnabled,
getSwapsDefaultToken,
SwapsEthToken,
-} from '../../selectors';
+} from '../../selectors/selectors';
import { ALLOWED_BRIDGE_CHAIN_IDS } from '../../../shared/constants/bridge';
import {
BridgeControllerState,
@@ -110,7 +110,7 @@ export const getToTokens = (state: BridgeAppState) => {
export const getFromToken = (
state: BridgeAppState,
-): SwapsTokenObject | SwapsEthToken => {
+): SwapsTokenObject | SwapsEthToken | null => {
return state.bridge.fromToken?.address
? state.bridge.fromToken
: getSwapsDefaultToken(state);
diff --git a/ui/ducks/metamask/metamask.js b/ui/ducks/metamask/metamask.js
index 05cc6d46cb27..9627608eb709 100644
--- a/ui/ducks/metamask/metamask.js
+++ b/ui/ducks/metamask/metamask.js
@@ -17,9 +17,9 @@ import {
checkNetworkAndAccountSupports1559,
getAddressBook,
getSelectedNetworkClientId,
- getSelectedInternalAccount,
getNetworkConfigurationsByChainId,
-} from '../../selectors';
+} from '../../selectors/selectors';
+import { getSelectedInternalAccount } from '../../selectors/accounts';
import * as actionConstants from '../../store/actionConstants';
import { updateTransactionGasFees } from '../../store/actions';
import { setCustomGasLimit, setCustomGasPrice } from '../gas/gas.duck';
@@ -50,6 +50,7 @@ const initialState = {
smartTransactionsOptInStatus: false,
petnamesEnabled: true,
featureNotificationsEnabled: false,
+ privacyMode: false,
showMultiRpcModal: false,
},
firstTimeFlowType: null,
diff --git a/ui/ducks/ramps/ramps.test.ts b/ui/ducks/ramps/ramps.test.ts
index 3cd543a65219..8bd6865295d8 100644
--- a/ui/ducks/ramps/ramps.test.ts
+++ b/ui/ducks/ramps/ramps.test.ts
@@ -205,7 +205,7 @@ describe('rampsSlice', () => {
});
it('should return true when Bitcoin is buyable and current chain is Bitcoin', () => {
- getCurrentChainIdMock.mockReturnValue(MultichainNetworks.BITCOIN);
+ getCurrentChainIdMock.mockReturnValue(CHAIN_IDS.MAINNET);
getMultichainIsBitcoinMock.mockReturnValue(true);
const mockBuyableChains = [
{ chainId: MultichainNetworks.BITCOIN, active: true },
@@ -219,7 +219,7 @@ describe('rampsSlice', () => {
});
it('should return false when Bitcoin is not buyable and current chain is Bitcoin', () => {
- getCurrentChainIdMock.mockReturnValue(MultichainNetworks.BITCOIN);
+ getCurrentChainIdMock.mockReturnValue(CHAIN_IDS.MAINNET);
getMultichainIsBitcoinMock.mockReturnValue(true);
const mockBuyableChains = [
{ chainId: MultichainNetworks.BITCOIN, active: false },
diff --git a/ui/ducks/swaps/swaps.js b/ui/ducks/swaps/swaps.js
index 8dd7336d7a62..d23c0ce69381 100644
--- a/ui/ducks/swaps/swaps.js
+++ b/ui/ducks/swaps/swaps.js
@@ -858,6 +858,7 @@ export const fetchQuotesAndSetQuoteState = (
stx_enabled: smartTransactionsEnabled,
current_stx_enabled: currentSmartTransactionsEnabled,
stx_user_opt_in: getSmartTransactionsOptInStatusForMetrics(state),
+ gas_included: newSelectedQuote.isGasIncludedTrade,
anonymizedData: true,
},
});
diff --git a/ui/helpers/utils/notification.util.ts b/ui/helpers/utils/notification.util.ts
index 489f1ca2f272..afbba2b88172 100644
--- a/ui/helpers/utils/notification.util.ts
+++ b/ui/helpers/utils/notification.util.ts
@@ -420,9 +420,11 @@ export const getNetworkFees = async (notification: OnChainRawNotification) => {
const rpcUrl = getRpcUrlByChainId(`0x${chainId}` as HexChainId);
const connection = {
url: rpcUrl,
- headers: {
- 'Infura-Source': 'metamask/metamask',
- },
+ headers: process.env.STORYBOOK
+ ? undefined
+ : {
+ 'Infura-Source': 'metamask/metamask',
+ },
};
const provider = new JsonRpcProvider(connection);
diff --git a/ui/helpers/utils/notification.utils.test.ts b/ui/helpers/utils/notification.utils.test.ts
index f82f8532cb58..2f4e66c26504 100644
--- a/ui/helpers/utils/notification.utils.test.ts
+++ b/ui/helpers/utils/notification.utils.test.ts
@@ -9,7 +9,7 @@ import {
describe('formatMenuItemDate', () => {
beforeAll(() => {
jest.useFakeTimers();
- jest.setSystemTime(new Date('2024-06-07T09:40:00Z'));
+ jest.setSystemTime(new Date(Date.UTC(2024, 5, 7, 9, 40, 0))); // 2024-06-07T09:40:00Z
});
afterAll(() => {
@@ -28,7 +28,7 @@ describe('formatMenuItemDate', () => {
// assert 1 hour ago
assertToday((testDate) => {
- testDate.setHours(testDate.getHours() - 1);
+ testDate.setUTCHours(testDate.getUTCHours() - 1);
return testDate;
});
});
@@ -42,14 +42,14 @@ describe('formatMenuItemDate', () => {
// assert exactly 1 day ago
assertYesterday((testDate) => {
- testDate.setDate(testDate.getDate() - 1);
+ testDate.setUTCDate(testDate.getUTCDate() - 1);
});
// assert almost a day ago, but was still yesterday
// E.g. if Today way 09:40AM, but date to test was 23 hours ago (yesterday at 10:40AM), we still want to to show yesterday
assertYesterday((testDate) => {
- testDate.setDate(testDate.getDate() - 1);
- testDate.setHours(testDate.getHours() + 1);
+ testDate.setUTCDate(testDate.getUTCDate() - 1);
+ testDate.setUTCHours(testDate.getUTCHours() + 1);
});
});
@@ -62,18 +62,18 @@ describe('formatMenuItemDate', () => {
// assert exactly 1 month ago
assertMonthsAgo((testDate) => {
- testDate.setMonth(testDate.getMonth() - 1);
+ testDate.setUTCMonth(testDate.getUTCMonth() - 1);
});
// assert 2 months ago
assertMonthsAgo((testDate) => {
- testDate.setMonth(testDate.getMonth() - 2);
+ testDate.setUTCMonth(testDate.getUTCMonth() - 2);
});
// assert almost a month ago (where it is a new month, but not 30 days)
assertMonthsAgo(() => {
// jest mock date is set in july, so we will test with month may
- return new Date('2024-05-20T09:40:00Z');
+ return new Date(Date.UTC(2024, 4, 20, 9, 40, 0)); // 2024-05-20T09:40:00Z
});
});
@@ -86,18 +86,18 @@ describe('formatMenuItemDate', () => {
// assert exactly 1 year ago
assertYearsAgo((testDate) => {
- testDate.setFullYear(testDate.getFullYear() - 1);
+ testDate.setUTCFullYear(testDate.getUTCFullYear() - 1);
});
// assert 2 years ago
assertYearsAgo((testDate) => {
- testDate.setFullYear(testDate.getFullYear() - 2);
+ testDate.setUTCFullYear(testDate.getUTCFullYear() - 2);
});
// assert almost a year ago (where it is a new year, but not 365 days ago)
assertYearsAgo(() => {
// jest mock date is set in 2024, so we will test with year 2023
- return new Date('2023-11-20T09:40:00Z');
+ return new Date(Date.UTC(2023, 10, 20, 9, 40, 0)); // 2023-11-20T09:40:00Z
});
});
});
diff --git a/ui/hooks/bridge/useBridging.ts b/ui/hooks/bridge/useBridging.ts
index a68aeb361bdd..fe7a21e2206f 100644
--- a/ui/hooks/bridge/useBridging.ts
+++ b/ui/hooks/bridge/useBridging.ts
@@ -43,6 +43,7 @@ const useBridging = () => {
const isMarketingEnabled = useSelector(getDataCollectionForMarketing);
const providerConfig = useSelector(getProviderConfig);
const keyring = useSelector(getCurrentKeyring);
+ // @ts-expect-error keyring type is wrong maybe?
const usingHardwareWallet = isHardwareKeyring(keyring.type);
const isBridgeSupported = useSelector(getIsBridgeEnabled);
diff --git a/ui/hooks/metamask-notifications/useNotifications.ts b/ui/hooks/metamask-notifications/useNotifications.ts
index 62367cdbe310..9724253a8671 100644
--- a/ui/hooks/metamask-notifications/useNotifications.ts
+++ b/ui/hooks/metamask-notifications/useNotifications.ts
@@ -54,8 +54,13 @@ export function useListNotifications(): {
setLoading(true);
setError(null);
+ const urlParams = new URLSearchParams(window.location.search);
+ const previewToken = urlParams.get('previewToken');
+
try {
- const data = await dispatch(fetchAndUpdateMetamaskNotifications());
+ const data = await dispatch(
+ fetchAndUpdateMetamaskNotifications(previewToken ?? undefined),
+ );
setNotificationsData(data as unknown as Notification[]);
return data as unknown as Notification[];
} catch (e) {
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing.test.tsx b/ui/hooks/metamask-notifications/useProfileSyncing.test.tsx
deleted file mode 100644
index 951cec333ade..000000000000
--- a/ui/hooks/metamask-notifications/useProfileSyncing.test.tsx
+++ /dev/null
@@ -1,156 +0,0 @@
-import React from 'react';
-import { Provider } from 'react-redux';
-import { renderHook, act } from '@testing-library/react-hooks';
-import configureStore from 'redux-mock-store';
-import thunk from 'redux-thunk';
-import { waitFor } from '@testing-library/react';
-import * as actions from '../../store/actions';
-import {
- useEnableProfileSyncing,
- useDisableProfileSyncing,
- useAccountSyncingEffect,
- useDeleteAccountSyncingDataFromUserStorage,
-} from './useProfileSyncing';
-
-const middlewares = [thunk];
-const mockStore = configureStore(middlewares);
-
-jest.mock('../../store/actions', () => ({
- performSignIn: jest.fn(),
- performSignOut: jest.fn(),
- enableProfileSyncing: jest.fn(),
- disableProfileSyncing: jest.fn(),
- showLoadingIndication: jest.fn(),
- hideLoadingIndication: jest.fn(),
- syncInternalAccountsWithUserStorage: jest.fn(),
- deleteAccountSyncingDataFromUserStorage: jest.fn(),
-}));
-
-type ArrangeMocksMetamaskStateOverrides = {
- isSignedIn?: boolean;
- isProfileSyncingEnabled?: boolean;
- isUnlocked?: boolean;
- useExternalServices?: boolean;
- completedOnboarding?: boolean;
-};
-
-const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = {
- isSignedIn: false,
- isProfileSyncingEnabled: false,
- isUnlocked: true,
- useExternalServices: true,
- completedOnboarding: true,
-};
-
-const arrangeMocks = (
- metamaskStateOverrides?: ArrangeMocksMetamaskStateOverrides,
-) => {
- const store = mockStore({
- metamask: {
- ...initialMetamaskState,
- ...metamaskStateOverrides,
- participateInMetaMetrics: false,
- internalAccounts: {
- accounts: {
- '0x123': {
- address: '0x123',
- id: 'account1',
- metadata: {},
- options: {},
- methods: [],
- type: 'eip155:eoa',
- },
- },
- },
- },
- });
-
- store.dispatch = jest.fn().mockImplementation((action) => {
- if (typeof action === 'function') {
- return action(store.dispatch, store.getState);
- }
- return Promise.resolve();
- });
-
- jest.clearAllMocks();
-
- return { store };
-};
-
-describe('useProfileSyncing', () => {
- it('should enable profile syncing', async () => {
- const { store } = arrangeMocks();
-
- const { result } = renderHook(() => useEnableProfileSyncing(), {
- wrapper: ({ children }) => {children},
- });
-
- act(() => {
- result.current.enableProfileSyncing();
- });
-
- expect(actions.enableProfileSyncing).toHaveBeenCalled();
- });
-
- it('should disable profile syncing', async () => {
- const { store } = arrangeMocks();
-
- const { result } = renderHook(() => useDisableProfileSyncing(), {
- wrapper: ({ children }) => {children},
- });
-
- act(() => {
- result.current.disableProfileSyncing();
- });
-
- expect(actions.disableProfileSyncing).toHaveBeenCalled();
- });
-
- it('should dispatch account syncing when conditions are met', async () => {
- const { store } = arrangeMocks({
- isSignedIn: true,
- isProfileSyncingEnabled: true,
- });
-
- renderHook(() => useAccountSyncingEffect(), {
- wrapper: ({ children }) => {children},
- });
-
- await waitFor(() => {
- expect(actions.syncInternalAccountsWithUserStorage).toHaveBeenCalled();
- });
- });
-
- it('should not dispatch account syncing when conditions are not met', async () => {
- const { store } = arrangeMocks();
-
- renderHook(() => useAccountSyncingEffect(), {
- wrapper: ({ children }) => {children},
- });
-
- await waitFor(() => {
- expect(
- actions.syncInternalAccountsWithUserStorage,
- ).not.toHaveBeenCalled();
- });
- });
-
- it('should dispatch account sync data deletion', async () => {
- const { store } = arrangeMocks();
-
- const { result } = renderHook(
- () => useDeleteAccountSyncingDataFromUserStorage(),
- {
- wrapper: ({ children }) => (
- {children}
- ),
- },
- );
-
- act(() => {
- result.current.dispatchDeleteAccountSyncingDataFromUserStorage();
- });
-
- expect(actions.deleteAccountSyncingDataFromUserStorage).toHaveBeenCalled();
- });
-});
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.test.tsx b/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.test.tsx
new file mode 100644
index 000000000000..604466b3a75c
--- /dev/null
+++ b/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.test.tsx
@@ -0,0 +1,70 @@
+import { waitFor } from '@testing-library/react';
+import { act } from '@testing-library/react-hooks';
+import { renderHookWithProviderTyped } from '../../../../test/lib/render-helpers';
+import * as actions from '../../../store/actions';
+import {
+ useAccountSyncingEffect,
+ useDeleteAccountSyncingDataFromUserStorage,
+} from './accountSyncing';
+import * as ProfileSyncModule from './profileSyncing';
+
+describe('useDeleteAccountSyncingDataFromUserStorage()', () => {
+ it('should dispatch account sync data deletion', async () => {
+ const mockDeleteAccountSyncAction = jest.spyOn(
+ actions,
+ 'deleteAccountSyncingDataFromUserStorage',
+ );
+
+ const { result } = renderHookWithProviderTyped(
+ () => useDeleteAccountSyncingDataFromUserStorage(),
+ {},
+ );
+
+ await act(async () => {
+ await result.current.dispatchDeleteAccountData();
+ });
+
+ expect(mockDeleteAccountSyncAction).toHaveBeenCalled();
+ });
+});
+
+describe('useAccountSyncingEffect', () => {
+ const arrangeMocks = () => {
+ const mockUseShouldProfileSync = jest.spyOn(
+ ProfileSyncModule,
+ 'useShouldDispatchProfileSyncing',
+ );
+ const mockSyncAccountsAction = jest.spyOn(
+ actions,
+ 'syncInternalAccountsWithUserStorage',
+ );
+ return {
+ mockUseShouldProfileSync,
+ mockSyncAccountsAction,
+ };
+ };
+
+ const arrangeAndAct = (props: { profileSyncConditionsMet: boolean }) => {
+ const mocks = arrangeMocks();
+ mocks.mockUseShouldProfileSync.mockReturnValue(
+ props.profileSyncConditionsMet,
+ );
+
+ renderHookWithProviderTyped(() => useAccountSyncingEffect(), {});
+ return mocks;
+ };
+
+ it('should run effect if profile sync conditions are met', async () => {
+ const mocks = arrangeAndAct({ profileSyncConditionsMet: true });
+ await waitFor(() => {
+ expect(mocks.mockSyncAccountsAction).toHaveBeenCalled();
+ });
+ });
+
+ it('should not run effect if profile sync conditions are not met', async () => {
+ const mocks = arrangeAndAct({ profileSyncConditionsMet: false });
+ await waitFor(() => {
+ expect(mocks.mockSyncAccountsAction).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.ts b/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.ts
new file mode 100644
index 000000000000..cef4dc80fa75
--- /dev/null
+++ b/ui/hooks/metamask-notifications/useProfileSyncing/accountSyncing.ts
@@ -0,0 +1,66 @@
+import log from 'loglevel';
+import { useCallback, useEffect } from 'react';
+import { useDispatch } from 'react-redux';
+import {
+ deleteAccountSyncingDataFromUserStorage,
+ syncInternalAccountsWithUserStorage,
+} from '../../../store/actions';
+import { useShouldDispatchProfileSyncing } from './profileSyncing';
+
+/**
+ * Custom hook to dispatch account syncing.
+ *
+ * @returns An object containing the `dispatchAccountSyncing` function, boolean `shouldDispatchAccountSyncing`,
+ * and error state.
+ */
+const useAccountSyncing = () => {
+ const dispatch = useDispatch();
+
+ const shouldDispatchAccountSyncing = useShouldDispatchProfileSyncing();
+
+ const dispatchAccountSyncing = useCallback(() => {
+ try {
+ if (!shouldDispatchAccountSyncing) {
+ return;
+ }
+ dispatch(syncInternalAccountsWithUserStorage());
+ } catch (e) {
+ log.error(e);
+ }
+ }, [dispatch, shouldDispatchAccountSyncing]);
+
+ return {
+ dispatchAccountSyncing,
+ shouldDispatchAccountSyncing,
+ };
+};
+
+/**
+ * Custom hook to apply account syncing effect.
+ */
+export const useAccountSyncingEffect = () => {
+ const shouldSync = useShouldDispatchProfileSyncing();
+ const { dispatchAccountSyncing } = useAccountSyncing();
+
+ useEffect(() => {
+ if (shouldSync) {
+ dispatchAccountSyncing();
+ }
+ }, [shouldSync, dispatchAccountSyncing]);
+};
+
+/**
+ * Custom hook to delete a user's account syncing data from user storage
+ */
+export const useDeleteAccountSyncingDataFromUserStorage = () => {
+ const dispatch = useDispatch();
+ const dispatchDeleteAccountData = useCallback(async () => {
+ try {
+ await dispatch(deleteAccountSyncingDataFromUserStorage());
+ } catch {
+ // Do Nothing
+ }
+ }, []);
+
+ return { dispatchDeleteAccountData };
+};
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/index.ts b/ui/hooks/metamask-notifications/useProfileSyncing/index.ts
new file mode 100644
index 000000000000..9a6cda8468fb
--- /dev/null
+++ b/ui/hooks/metamask-notifications/useProfileSyncing/index.ts
@@ -0,0 +1,9 @@
+export {
+ useDisableProfileSyncing,
+ useEnableProfileSyncing,
+ useSetIsProfileSyncingEnabled,
+} from './profileSyncing';
+export {
+ useAccountSyncingEffect,
+ useDeleteAccountSyncingDataFromUserStorage,
+} from './accountSyncing';
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx
new file mode 100644
index 000000000000..99d3064085ea
--- /dev/null
+++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.test.tsx
@@ -0,0 +1,136 @@
+import { act } from '@testing-library/react-hooks';
+import { renderHookWithProviderTyped } from '../../../../test/lib/render-helpers';
+import { MetamaskNotificationsProvider } from '../../../contexts/metamask-notifications';
+import * as actions from '../../../store/actions';
+import {
+ useDisableProfileSyncing,
+ useEnableProfileSyncing,
+ useShouldDispatchProfileSyncing,
+} from './profileSyncing';
+
+type ArrangeMocksMetamaskStateOverrides = {
+ isSignedIn?: boolean;
+ isProfileSyncingEnabled?: boolean;
+ isUnlocked?: boolean;
+ useExternalServices?: boolean;
+ completedOnboarding?: boolean;
+};
+
+const initialMetamaskState: ArrangeMocksMetamaskStateOverrides = {
+ isSignedIn: false,
+ isProfileSyncingEnabled: false,
+ isUnlocked: true,
+ useExternalServices: true,
+ completedOnboarding: true,
+};
+
+const arrangeMockState = (
+ metamaskStateOverrides?: ArrangeMocksMetamaskStateOverrides,
+) => {
+ const state = {
+ metamask: {
+ ...initialMetamaskState,
+ ...metamaskStateOverrides,
+ },
+ };
+
+ return { state };
+};
+
+describe('useEnableProfileSyncing()', () => {
+ it('should enable profile syncing', async () => {
+ const mockEnableProfileSyncingAction = jest.spyOn(
+ actions,
+ 'enableProfileSyncing',
+ );
+
+ const { state } = arrangeMockState();
+ const { result } = renderHookWithProviderTyped(
+ () => useEnableProfileSyncing(),
+ state,
+ );
+ await act(async () => {
+ await result.current.enableProfileSyncing();
+ });
+
+ expect(mockEnableProfileSyncingAction).toHaveBeenCalled();
+ });
+});
+
+describe('useDisableProfileSyncing()', () => {
+ it('should disable profile syncing', async () => {
+ const mockDisableProfileSyncingAction = jest.spyOn(
+ actions,
+ 'disableProfileSyncing',
+ );
+
+ const { state } = arrangeMockState();
+
+ const { result } = renderHookWithProviderTyped(
+ () => useDisableProfileSyncing(),
+ state,
+ undefined,
+ MetamaskNotificationsProvider,
+ );
+
+ await act(async () => {
+ await result.current.disableProfileSyncing();
+ });
+
+ expect(mockDisableProfileSyncingAction).toHaveBeenCalled();
+ });
+});
+
+describe('useShouldDispatchProfileSyncing()', () => {
+ const testCases = (() => {
+ const properties = [
+ 'isSignedIn',
+ 'isProfileSyncingEnabled',
+ 'isUnlocked',
+ 'useExternalServices',
+ 'completedOnboarding',
+ ] as const;
+ const baseState = {
+ isSignedIn: true,
+ isProfileSyncingEnabled: true,
+ isUnlocked: true,
+ useExternalServices: true,
+ completedOnboarding: true,
+ };
+
+ const failureStateCases: {
+ state: ArrangeMocksMetamaskStateOverrides;
+ failingField: string;
+ }[] = [];
+
+ // Generate test cases by toggling each property
+ properties.forEach((property) => {
+ const state = { ...baseState, [property]: false };
+ failureStateCases.push({ state, failingField: property });
+ });
+
+ const successTestCase = { state: baseState };
+
+ return { successTestCase, failureStateCases };
+ })();
+
+ it('should return true if all conditions are met', () => {
+ const { state } = arrangeMockState(testCases.successTestCase.state);
+ const hook = renderHookWithProviderTyped(
+ () => useShouldDispatchProfileSyncing(),
+ state,
+ );
+ expect(hook.result.current).toBe(true);
+ });
+
+ testCases.failureStateCases.forEach(({ state, failingField }) => {
+ it(`should return false if not all conditions are met [${failingField} = false]`, () => {
+ const { state: newState } = arrangeMockState(state);
+ const hook = renderHookWithProviderTyped(
+ () => useShouldDispatchProfileSyncing(),
+ newState,
+ );
+ expect(hook.result.current).toBe(false);
+ });
+ });
+});
diff --git a/ui/hooks/metamask-notifications/useProfileSyncing.ts b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts
similarity index 53%
rename from ui/hooks/metamask-notifications/useProfileSyncing.ts
rename to ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts
index 67899aa73927..5c073fdf6d94 100644
--- a/ui/hooks/metamask-notifications/useProfileSyncing.ts
+++ b/ui/hooks/metamask-notifications/useProfileSyncing/profileSyncing.ts
@@ -1,35 +1,21 @@
-import { useState, useCallback, useEffect, useMemo } from 'react';
+import { useState, useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
-import type { InternalAccount } from '@metamask/keyring-api';
import log from 'loglevel';
+import { useMetamaskNotificationsContext } from '../../../contexts/metamask-notifications/metamask-notifications';
import {
disableProfileSyncing as disableProfileSyncingAction,
enableProfileSyncing as enableProfileSyncingAction,
setIsProfileSyncingEnabled as setIsProfileSyncingEnabledAction,
hideLoadingIndication,
- syncInternalAccountsWithUserStorage,
- deleteAccountSyncingDataFromUserStorage,
-} from '../../store/actions';
+} from '../../../store/actions';
-import { selectIsSignedIn } from '../../selectors/metamask-notifications/authentication';
-import { selectIsProfileSyncingEnabled } from '../../selectors/metamask-notifications/profile-syncing';
-import { getUseExternalServices } from '../../selectors';
+import { selectIsSignedIn } from '../../../selectors/metamask-notifications/authentication';
+import { selectIsProfileSyncingEnabled } from '../../../selectors/metamask-notifications/profile-syncing';
+import { getUseExternalServices } from '../../../selectors';
import {
getIsUnlocked,
getCompletedOnboarding,
-} from '../../ducks/metamask/metamask';
-
-// Define KeyringType interface
-export type KeyringType = {
- type: string;
-};
-
-// Define AccountType interface
-export type AccountType = InternalAccount & {
- balance: string;
- keyring: KeyringType;
- label: string;
-};
+} from '../../../ducks/metamask/metamask';
/**
* Custom hook to enable profile syncing. This hook handles the process of signing in
@@ -74,6 +60,7 @@ export function useDisableProfileSyncing(): {
error: string | null;
} {
const dispatch = useDispatch();
+ const { listNotifications } = useMetamaskNotificationsContext();
const [error, setError] = useState(null);
@@ -83,6 +70,9 @@ export function useDisableProfileSyncing(): {
try {
// disable profile syncing
await dispatch(disableProfileSyncingAction());
+
+ // list notifications to update the counter
+ await listNotifications();
} catch (e) {
const errorMessage =
e instanceof Error ? e.message : JSON.stringify(e ?? '');
@@ -124,92 +114,29 @@ export function useSetIsProfileSyncingEnabled(): {
}
/**
- * Custom hook to dispatch account syncing.
+ * A utility used internally to decide if syncing features should be dispatched
+ * Considers factors like basic functionality; unlocked; finished onboarding, and is logged in
*
- * @returns An object containing the `dispatchAccountSyncing` function, boolean `shouldDispatchAccountSyncing`,
- * and error state.
+ * @returns a boolean if internally we can perform syncing features or not.
*/
-export const useAccountSyncing = () => {
- const dispatch = useDispatch();
-
- const [error, setError] = useState(null);
-
+export const useShouldDispatchProfileSyncing = () => {
const isProfileSyncingEnabled = useSelector(selectIsProfileSyncingEnabled);
- const basicFunctionality = useSelector(getUseExternalServices);
- const isUnlocked = useSelector(getIsUnlocked);
+ const basicFunctionality: boolean | undefined = useSelector(
+ getUseExternalServices,
+ );
+ const isUnlocked: boolean | undefined = useSelector(getIsUnlocked);
const isSignedIn = useSelector(selectIsSignedIn);
- const completedOnboarding = useSelector(getCompletedOnboarding);
+ const completedOnboarding: boolean | undefined = useSelector(
+ getCompletedOnboarding,
+ );
- const shouldDispatchAccountSyncing = useMemo(
- () =>
- basicFunctionality &&
+ const shouldDispatchProfileSyncing: boolean = Boolean(
+ basicFunctionality &&
isProfileSyncingEnabled &&
isUnlocked &&
isSignedIn &&
completedOnboarding,
- [
- basicFunctionality,
- isProfileSyncingEnabled,
- isUnlocked,
- isSignedIn,
- completedOnboarding,
- ],
);
- const dispatchAccountSyncing = useCallback(() => {
- setError(null);
-
- try {
- if (!shouldDispatchAccountSyncing) {
- return;
- }
- dispatch(syncInternalAccountsWithUserStorage());
- } catch (e) {
- log.error(e);
- setError(e instanceof Error ? e.message : 'An unexpected error occurred');
- }
- }, [dispatch, shouldDispatchAccountSyncing]);
-
- return {
- dispatchAccountSyncing,
- shouldDispatchAccountSyncing,
- error,
- };
-};
-
-/**
- * Custom hook to delete a user's account syncing data from user storage
- */
-
-export const useDeleteAccountSyncingDataFromUserStorage = () => {
- const dispatch = useDispatch();
-
- const [error, setError] = useState(null);
-
- const dispatchDeleteAccountSyncingDataFromUserStorage = useCallback(() => {
- setError(null);
-
- try {
- dispatch(deleteAccountSyncingDataFromUserStorage());
- } catch (e) {
- log.error(e);
- setError(e instanceof Error ? e.message : 'An unexpected error occurred');
- }
- }, [dispatch]);
-
- return {
- dispatchDeleteAccountSyncingDataFromUserStorage,
- error,
- };
-};
-
-/**
- * Custom hook to apply account syncing effect.
- */
-export const useAccountSyncingEffect = () => {
- const { dispatchAccountSyncing } = useAccountSyncing();
-
- useEffect(() => {
- dispatchAccountSyncing();
- }, [dispatchAccountSyncing]);
+ return shouldDispatchProfileSyncing;
};
diff --git a/ui/hooks/snaps/useDisplayName.ts b/ui/hooks/snaps/useDisplayName.ts
new file mode 100644
index 000000000000..6a6d3d7e6b51
--- /dev/null
+++ b/ui/hooks/snaps/useDisplayName.ts
@@ -0,0 +1,54 @@
+import { NamespaceId } from '@metamask/snaps-utils';
+import { CaipChainId, KnownCaipNamespace } from '@metamask/utils';
+import { useSelector } from 'react-redux';
+import {
+ getMemoizedAccountName,
+ getAddressBookEntryByNetwork,
+ AddressBookMetaMaskState,
+ AccountsMetaMaskState,
+} from '../../selectors/snaps';
+import { toChecksumHexAddress } from '../../../shared/modules/hexstring-utils';
+import { decimalToHex } from '../../../shared/modules/conversion.utils';
+
+export type UseDisplayNameParams = {
+ chain: {
+ namespace: NamespaceId;
+ reference: string;
+ };
+ chainId: CaipChainId;
+ address: string;
+};
+
+/**
+ * Get the display name for an address.
+ * This will look for an account name in the state, and if not found, it will look for an address book entry.
+ *
+ * @param params - The parsed CAIP-10 ID.
+ * @returns The display name for the address.
+ */
+export const useDisplayName = (
+ params: UseDisplayNameParams,
+): string | undefined => {
+ const {
+ address,
+ chain: { namespace, reference },
+ } = params;
+
+ const isEip155 = namespace === KnownCaipNamespace.Eip155;
+
+ const parsedAddress = isEip155 ? toChecksumHexAddress(address) : address;
+
+ const accountName = useSelector((state: AccountsMetaMaskState) =>
+ getMemoizedAccountName(state, parsedAddress),
+ );
+
+ const addressBookEntry = useSelector((state: AddressBookMetaMaskState) =>
+ getAddressBookEntryByNetwork(
+ state,
+ parsedAddress,
+ `0x${decimalToHex(isEip155 ? reference : `0`)}`,
+ ),
+ );
+
+ return accountName || (isEip155 && addressBookEntry?.name) || undefined;
+};
diff --git a/ui/hooks/useCurrencyRatePolling.ts b/ui/hooks/useCurrencyRatePolling.ts
index fb14b1c94797..e7ad21adedf5 100644
--- a/ui/hooks/useCurrencyRatePolling.ts
+++ b/ui/hooks/useCurrencyRatePolling.ts
@@ -1,24 +1,30 @@
import { useSelector } from 'react-redux';
import {
- getSelectedNetworkClientId,
+ getNetworkConfigurationsByChainId,
getUseCurrencyRateCheck,
} from '../selectors';
import {
- currencyRateStartPollingByNetworkClientId,
+ currencyRateStartPolling,
currencyRateStopPollingByPollingToken,
} from '../store/actions';
import { getCompletedOnboarding } from '../ducks/metamask/metamask';
import usePolling from './usePolling';
-const useCurrencyRatePolling = (networkClientId?: string) => {
+const useCurrencyRatePolling = () => {
const useCurrencyRateCheck = useSelector(getUseCurrencyRateCheck);
const completedOnboarding = useSelector(getCompletedOnboarding);
- const selectedNetworkClientId = useSelector(getSelectedNetworkClientId);
+ const networkConfigurations = useSelector(getNetworkConfigurationsByChainId);
+
+ const nativeCurrencies = [
+ ...new Set(
+ Object.values(networkConfigurations).map((n) => n.nativeCurrency),
+ ),
+ ];
usePolling({
- startPollingByNetworkClientId: currencyRateStartPollingByNetworkClientId,
+ startPolling: currencyRateStartPolling,
stopPollingByPollingToken: currencyRateStopPollingByPollingToken,
- networkClientId: networkClientId ?? selectedNetworkClientId,
+ input: nativeCurrencies,
enabled: useCurrencyRateCheck && completedOnboarding,
});
};
diff --git a/ui/hooks/useDisplayName.test.ts b/ui/hooks/useDisplayName.test.ts
index 1d6fb22b5e69..5c36b0a97ed2 100644
--- a/ui/hooks/useDisplayName.test.ts
+++ b/ui/hooks/useDisplayName.test.ts
@@ -1,218 +1,533 @@
-import { NameEntry, NameType } from '@metamask/name-controller';
-import { NftContract } from '@metamask/assets-controllers';
-import { renderHook } from '@testing-library/react-hooks';
-import { getRemoteTokens } from '../selectors';
-import { getNftContractsByAddressOnCurrentChain } from '../selectors/nft';
+import { NameType } from '@metamask/name-controller';
+import { CHAIN_IDS } from '@metamask/transaction-controller';
+import { cloneDeep } from 'lodash';
+import { Hex } from '@metamask/utils';
+import { renderHookWithProvider } from '../../test/lib/render-helpers';
+import mockState from '../../test/data/mock-state.json';
+import {
+ EXPERIENCES_TYPE,
+ FIRST_PARTY_CONTRACT_NAMES,
+} from '../../shared/constants/first-party-contracts';
import { useDisplayName } from './useDisplayName';
-import { useNames } from './useName';
-import { useFirstPartyContractNames } from './useFirstPartyContractName';
import { useNftCollectionsMetadata } from './useNftCollectionsMetadata';
+import { useNames } from './useName';
-jest.mock('react-redux', () => ({
- // TODO: Replace `any` with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- useSelector: (selector: any) => selector(),
-}));
-
-jest.mock('./useName', () => ({
- useNames: jest.fn(),
-}));
-
-jest.mock('./useFirstPartyContractName', () => ({
- useFirstPartyContractNames: jest.fn(),
-}));
-
-jest.mock('./useNftCollectionsMetadata', () => ({
- useNftCollectionsMetadata: jest.fn(),
-}));
-
-jest.mock('../selectors', () => ({
- getRemoteTokens: jest.fn(),
- getCurrentChainId: jest.fn(),
-}));
-
-jest.mock('../selectors/nft', () => ({
- getNftContractsByAddressOnCurrentChain: jest.fn(),
-}));
-
-const VALUE_MOCK = '0xabc123';
-const TYPE_MOCK = NameType.ETHEREUM_ADDRESS;
-const NAME_MOCK = 'TestName';
-const CONTRACT_NAME_MOCK = 'TestContractName';
-const FIRST_PARTY_CONTRACT_NAME_MOCK = 'MetaMask Bridge';
-const WATCHED_NFT_NAME_MOCK = 'TestWatchedNFTName';
-
-const NO_PETNAME_FOUND_RETURN_VALUE = {
- name: null,
-} as NameEntry;
-const NO_CONTRACT_NAME_FOUND_RETURN_VALUE = undefined;
-const NO_FIRST_PARTY_CONTRACT_NAME_FOUND_RETURN_VALUE = null;
-const NO_WATCHED_NFT_NAME_FOUND_RETURN_VALUE = {};
-
-const PETNAME_FOUND_RETURN_VALUE = {
- name: NAME_MOCK,
-} as NameEntry;
-
-const WATCHED_NFT_FOUND_RETURN_VALUE = {
- [VALUE_MOCK]: {
- name: WATCHED_NFT_NAME_MOCK,
- } as NftContract,
-};
+jest.mock('./useName');
+jest.mock('./useNftCollectionsMetadata');
+
+const VALUE_MOCK = 'testvalue';
+const VARIATION_MOCK = CHAIN_IDS.GOERLI;
+const PETNAME_MOCK = 'testName1';
+const ERC20_TOKEN_NAME_MOCK = 'testName2';
+const WATCHED_NFT_NAME_MOCK = 'testName3';
+const NFT_NAME_MOCK = 'testName4';
+const FIRST_PARTY_CONTRACT_NAME_MOCK = 'testName5';
+const SYMBOL_MOCK = 'tes';
+const NFT_IMAGE_MOCK = 'testNftImage';
+const ERC20_IMAGE_MOCK = 'testImage';
+const OTHER_NAME_TYPE = 'test' as NameType;
describe('useDisplayName', () => {
const useNamesMock = jest.mocked(useNames);
- const getRemoteTokensMock = jest.mocked(getRemoteTokens);
- const useFirstPartyContractNamesMock = jest.mocked(
- useFirstPartyContractNames,
- );
- const getNftContractsByAddressOnCurrentChainMock = jest.mocked(
- getNftContractsByAddressOnCurrentChain,
- );
const useNftCollectionsMetadataMock = jest.mocked(useNftCollectionsMetadata);
- beforeEach(() => {
- jest.resetAllMocks();
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let state: any;
- useNamesMock.mockReturnValue([NO_PETNAME_FOUND_RETURN_VALUE]);
- useFirstPartyContractNamesMock.mockReturnValue([
- NO_FIRST_PARTY_CONTRACT_NAME_FOUND_RETURN_VALUE,
- ]);
- getRemoteTokensMock.mockReturnValue([
+ function mockPetname(name: string) {
+ useNamesMock.mockReturnValue([
{
- name: NO_CONTRACT_NAME_FOUND_RETURN_VALUE,
+ name,
+ sourceId: null,
+ proposedNames: {},
+ origin: null,
},
]);
- getNftContractsByAddressOnCurrentChainMock.mockReturnValue(
- NO_WATCHED_NFT_NAME_FOUND_RETURN_VALUE,
- );
- useNftCollectionsMetadataMock.mockReturnValue({});
- });
+ }
+
+ function mockERC20Token(
+ value: string,
+ variation: string,
+ name: string,
+ symbol: string,
+ image: string,
+ ) {
+ state.metamask.tokensChainsCache = {
+ [variation]: {
+ data: {
+ [value]: {
+ name,
+ symbol,
+ iconUrl: image,
+ },
+ },
+ },
+ };
+ }
- it('handles no name found', () => {
- const { result } = renderHook(() => useDisplayName(VALUE_MOCK, TYPE_MOCK));
- expect(result.current).toEqual({
- name: null,
- hasPetname: false,
+ function mockWatchedNFTName(value: string, variation: string, name: string) {
+ state.metamask.allNftContracts = {
+ '0x123': {
+ [variation]: [{ address: value, name }],
+ },
+ };
+ }
+
+ function mockNFT(
+ value: string,
+ variation: string,
+ name: string,
+ image: string,
+ isSpam: boolean,
+ ) {
+ useNftCollectionsMetadataMock.mockReturnValue({
+ [variation]: {
+ [value]: { name, image, isSpam },
+ },
});
- });
+ }
+
+ function mockFirstPartyContractName(
+ value: string,
+ variation: string,
+ name: string,
+ ) {
+ FIRST_PARTY_CONTRACT_NAMES[name as EXPERIENCES_TYPE] = {
+ [variation as Hex]: value as Hex,
+ };
+ }
- it('prioritizes a petname over all else', () => {
- useNamesMock.mockReturnValue([PETNAME_FOUND_RETURN_VALUE]);
- useFirstPartyContractNamesMock.mockReturnValue([
- FIRST_PARTY_CONTRACT_NAME_MOCK,
- ]);
- getRemoteTokensMock.mockReturnValue([
+ beforeEach(() => {
+ jest.resetAllMocks();
+
+ useNftCollectionsMetadataMock.mockReturnValue({});
+
+ useNamesMock.mockReturnValue([
{
- name: CONTRACT_NAME_MOCK,
+ name: null,
+ sourceId: null,
+ proposedNames: {},
+ origin: null,
},
]);
- getNftContractsByAddressOnCurrentChainMock.mockReturnValue(
- WATCHED_NFT_FOUND_RETURN_VALUE,
- );
- const { result } = renderHook(() => useDisplayName(VALUE_MOCK, TYPE_MOCK));
+ state = cloneDeep(mockState);
- expect(result.current).toEqual({
- name: NAME_MOCK,
- hasPetname: true,
- contractDisplayName: CONTRACT_NAME_MOCK,
- });
+ delete FIRST_PARTY_CONTRACT_NAMES[
+ FIRST_PARTY_CONTRACT_NAME_MOCK as EXPERIENCES_TYPE
+ ];
});
- it('prioritizes a first-party contract name over a contract name and watched NFT name', () => {
- useFirstPartyContractNamesMock.mockReturnValue([
- FIRST_PARTY_CONTRACT_NAME_MOCK,
- ]);
- getRemoteTokensMock.mockReturnValue({
- name: CONTRACT_NAME_MOCK,
- });
- getNftContractsByAddressOnCurrentChainMock.mockReturnValue(
- WATCHED_NFT_FOUND_RETURN_VALUE,
+ it('returns no name if no defaults found', () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ mockState,
);
- const { result } = renderHook(() => useDisplayName(VALUE_MOCK, TYPE_MOCK));
-
- expect(result.current).toEqual({
- name: FIRST_PARTY_CONTRACT_NAME_MOCK,
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
hasPetname: false,
+ image: undefined,
+ name: null,
});
});
- it('prioritizes a contract name over a watched NFT name', () => {
- getRemoteTokensMock.mockReturnValue([
- {
- name: CONTRACT_NAME_MOCK,
- },
- ]);
- getNftContractsByAddressOnCurrentChainMock.mockReturnValue(
- WATCHED_NFT_FOUND_RETURN_VALUE,
- );
+ describe('Petname', () => {
+ it('returns petname', () => {
+ mockPetname(PETNAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: true,
+ image: undefined,
+ name: PETNAME_MOCK,
+ });
+ });
+ });
- const { result } = renderHook(() => useDisplayName(VALUE_MOCK, TYPE_MOCK));
+ describe('ERC-20 Token', () => {
+ it('returns ERC-20 token name and image', () => {
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: ERC20_TOKEN_NAME_MOCK,
+ hasPetname: false,
+ image: ERC20_IMAGE_MOCK,
+ name: ERC20_TOKEN_NAME_MOCK,
+ });
+ });
- expect(result.current).toEqual({
- name: CONTRACT_NAME_MOCK,
- hasPetname: false,
- contractDisplayName: CONTRACT_NAME_MOCK,
+ it('returns ERC-20 token symbol', () => {
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: CHAIN_IDS.GOERLI,
+ preferContractSymbol: true,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: SYMBOL_MOCK,
+ hasPetname: false,
+ image: ERC20_IMAGE_MOCK,
+ name: SYMBOL_MOCK,
+ });
});
- });
- it('returns a watched NFT name if no other name is found', () => {
- getNftContractsByAddressOnCurrentChainMock.mockReturnValue(
- WATCHED_NFT_FOUND_RETURN_VALUE,
- );
+ it('returns no name if type not address', () => {
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: OTHER_NAME_TYPE,
+ variation: CHAIN_IDS.GOERLI,
+ preferContractSymbol: true,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: null,
+ });
+ });
+ });
- const { result } = renderHook(() => useDisplayName(VALUE_MOCK, TYPE_MOCK));
+ describe('First-party Contract', () => {
+ it('returns first-party contract name', () => {
+ mockFirstPartyContractName(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ FIRST_PARTY_CONTRACT_NAME_MOCK,
+ );
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ mockState,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: FIRST_PARTY_CONTRACT_NAME_MOCK,
+ });
+ });
- expect(result.current).toEqual({
- name: WATCHED_NFT_NAME_MOCK,
- hasPetname: false,
+ it('returns no name if type is not address', () => {
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value:
+ FIRST_PARTY_CONTRACT_NAMES[EXPERIENCES_TYPE.METAMASK_BRIDGE][
+ CHAIN_IDS.OPTIMISM
+ ],
+ type: OTHER_NAME_TYPE,
+ variation: CHAIN_IDS.OPTIMISM,
+ }),
+ mockState,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: null,
+ });
});
});
- it('returns nft collection name from metadata if no other name is found', () => {
- const IMAGE_MOCK = 'url';
+ describe('Watched NFT', () => {
+ it('returns watched NFT name', () => {
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: WATCHED_NFT_NAME_MOCK,
+ });
+ });
- useNftCollectionsMetadataMock.mockReturnValue({
- [VALUE_MOCK.toLowerCase()]: {
- name: CONTRACT_NAME_MOCK,
- image: IMAGE_MOCK,
- isSpam: false,
- },
+ it('returns no name if type is not address', () => {
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: OTHER_NAME_TYPE,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: null,
+ });
+ });
+ });
+
+ describe('NFT', () => {
+ it('returns NFT name and image', () => {
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, false);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ mockState,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: NFT_IMAGE_MOCK,
+ name: NFT_NAME_MOCK,
+ });
});
- const { result } = renderHook(() =>
- useDisplayName(VALUE_MOCK, TYPE_MOCK, false),
- );
+ it('returns no name if NFT collection is spam', () => {
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, true);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ mockState,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: null,
+ });
+ });
- expect(result.current).toEqual({
- name: CONTRACT_NAME_MOCK,
- hasPetname: false,
- contractDisplayName: undefined,
- image: IMAGE_MOCK,
+ it('returns no name if type not address', () => {
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, false);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: OTHER_NAME_TYPE,
+ variation: VARIATION_MOCK,
+ }),
+ mockState,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: undefined,
+ hasPetname: false,
+ image: undefined,
+ name: null,
+ });
});
});
- it('does not return nft collection name if collection is marked as spam', () => {
- const IMAGE_MOCK = 'url';
+ describe('Priority', () => {
+ it('uses petname as first priority', () => {
+ mockPetname(PETNAME_MOCK);
+ mockFirstPartyContractName(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ FIRST_PARTY_CONTRACT_NAME_MOCK,
+ );
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, false);
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: ERC20_TOKEN_NAME_MOCK,
+ hasPetname: true,
+ image: NFT_IMAGE_MOCK,
+ name: PETNAME_MOCK,
+ });
+ });
- useNftCollectionsMetadataMock.mockReturnValue({
- [VALUE_MOCK.toLowerCase()]: {
- name: CONTRACT_NAME_MOCK,
- image: IMAGE_MOCK,
- isSpam: true,
- },
+ it('uses first-party contract name as second priority', () => {
+ mockFirstPartyContractName(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ FIRST_PARTY_CONTRACT_NAME_MOCK,
+ );
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, false);
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: ERC20_TOKEN_NAME_MOCK,
+ hasPetname: false,
+ image: NFT_IMAGE_MOCK,
+ name: FIRST_PARTY_CONTRACT_NAME_MOCK,
+ });
});
- const { result } = renderHook(() =>
- useDisplayName(VALUE_MOCK, TYPE_MOCK, false),
- );
+ it('uses NFT name as third priority', () => {
+ mockNFT(VALUE_MOCK, VARIATION_MOCK, NFT_NAME_MOCK, NFT_IMAGE_MOCK, false);
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: ERC20_TOKEN_NAME_MOCK,
+ hasPetname: false,
+ image: NFT_IMAGE_MOCK,
+ name: NFT_NAME_MOCK,
+ });
+ });
- expect(result.current).toEqual(
- expect.objectContaining({
- name: null,
- image: undefined,
- }),
- );
+ it('uses ERC-20 token name as fourth priority', () => {
+ mockERC20Token(
+ VALUE_MOCK,
+ VARIATION_MOCK,
+ ERC20_TOKEN_NAME_MOCK,
+ SYMBOL_MOCK,
+ ERC20_IMAGE_MOCK,
+ );
+ mockWatchedNFTName(VALUE_MOCK, VARIATION_MOCK, WATCHED_NFT_NAME_MOCK);
+
+ const { result } = renderHookWithProvider(
+ () =>
+ useDisplayName({
+ value: VALUE_MOCK,
+ type: NameType.ETHEREUM_ADDRESS,
+ variation: VARIATION_MOCK,
+ }),
+ state,
+ );
+
+ expect(result.current).toStrictEqual({
+ contractDisplayName: ERC20_TOKEN_NAME_MOCK,
+ hasPetname: false,
+ image: ERC20_IMAGE_MOCK,
+ name: ERC20_TOKEN_NAME_MOCK,
+ });
+ });
});
});
diff --git a/ui/hooks/useDisplayName.ts b/ui/hooks/useDisplayName.ts
index 64a878d2e357..7b7429c7a0d4 100644
--- a/ui/hooks/useDisplayName.ts
+++ b/ui/hooks/useDisplayName.ts
@@ -1,16 +1,20 @@
-import { useMemo } from 'react';
import { NameType } from '@metamask/name-controller';
import { useSelector } from 'react-redux';
-import { getRemoteTokens } from '../selectors';
-import { getNftContractsByAddressOnCurrentChain } from '../selectors/nft';
+import { Hex } from '@metamask/utils';
+import { selectERC20TokensByChain } from '../selectors';
+import { getNftContractsByAddressByChain } from '../selectors/nft';
+import {
+ EXPERIENCES_TYPE,
+ FIRST_PARTY_CONTRACT_NAMES,
+} from '../../shared/constants/first-party-contracts';
import { useNames } from './useName';
-import { useFirstPartyContractNames } from './useFirstPartyContractName';
import { useNftCollectionsMetadata } from './useNftCollectionsMetadata';
export type UseDisplayNameRequest = {
- value: string;
preferContractSymbol?: boolean;
type: NameType;
+ value: string;
+ variation: string;
};
export type UseDisplayNameResponse = {
@@ -23,79 +27,145 @@ export type UseDisplayNameResponse = {
export function useDisplayNames(
requests: UseDisplayNameRequest[],
): UseDisplayNameResponse[] {
- const nameRequests = useMemo(
- () => requests.map(({ value, type }) => ({ value, type })),
- [requests],
- );
-
- const nameEntries = useNames(nameRequests);
- const firstPartyContractNames = useFirstPartyContractNames(nameRequests);
- const nftCollections = useNftCollectionsMetadata(nameRequests);
- const values = requests.map(({ value }) => value);
-
- const contractInfo = useSelector((state) =>
- // TODO: Replace `any` with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- (getRemoteTokens as any)(state, values),
- );
+ const nameEntries = useNames(requests);
+ const firstPartyContractNames = useFirstPartyContractNames(requests);
+ const erc20Tokens = useERC20Tokens(requests);
+ const watchedNFTNames = useWatchedNFTNames(requests);
+ const nfts = useNFTs(requests);
- const watchedNftNames = useSelector(getNftContractsByAddressOnCurrentChain);
-
- return requests.map(({ value, preferContractSymbol }, index) => {
+ return requests.map((_request, index) => {
const nameEntry = nameEntries[index];
const firstPartyContractName = firstPartyContractNames[index];
- const singleContractInfo = contractInfo[index];
- const watchedNftName = watchedNftNames[value.toLowerCase()]?.name;
- const nftCollectionProperties = nftCollections[value.toLowerCase()];
-
- const isNotSpam = nftCollectionProperties?.isSpam === false;
-
- const nftCollectionName = isNotSpam
- ? nftCollectionProperties?.name
- : undefined;
- const nftCollectionImage = isNotSpam
- ? nftCollectionProperties?.image
- : undefined;
-
- const contractDisplayName =
- preferContractSymbol && singleContractInfo?.symbol
- ? singleContractInfo.symbol
- : singleContractInfo?.name;
+ const erc20Token = erc20Tokens[index];
+ const watchedNftName = watchedNFTNames[index];
+ const nft = nfts[index];
const name =
nameEntry?.name ||
firstPartyContractName ||
- nftCollectionName ||
- contractDisplayName ||
+ nft?.name ||
+ erc20Token?.name ||
watchedNftName ||
null;
+ const image = nft?.image || erc20Token?.image;
+
const hasPetname = Boolean(nameEntry?.name);
return {
name,
hasPetname,
- contractDisplayName,
- image: nftCollectionImage,
+ contractDisplayName: erc20Token?.name,
+ image,
};
});
}
-/**
- * Attempts to resolve the name for the given parameters.
- *
- * @param value - The address or contract address to resolve.
- * @param type - The type of value, e.g. NameType.ETHEREUM_ADDRESS.
- * @param preferContractSymbol - Applies to recognized contracts when no petname is saved:
- * If true the contract symbol (e.g. WBTC) will be used instead of the contract name.
- * @returns An object with two properties:
- * - `name` {string|null} - The display name, if it can be resolved, otherwise null.
- * - `hasPetname` {boolean} - True if there is a petname for the given address.
- */
export function useDisplayName(
- value: string,
- type: NameType,
- preferContractSymbol: boolean = false,
+ request: UseDisplayNameRequest,
): UseDisplayNameResponse {
- return useDisplayNames([{ preferContractSymbol, type, value }])[0];
+ return useDisplayNames([request])[0];
+}
+
+function useERC20Tokens(
+ nameRequests: UseDisplayNameRequest[],
+): ({ name?: string; image?: string } | undefined)[] {
+ const erc20TokensByChain = useSelector(selectERC20TokensByChain);
+
+ return nameRequests.map(
+ ({ preferContractSymbol, type, value, variation }) => {
+ if (type !== NameType.ETHEREUM_ADDRESS) {
+ return undefined;
+ }
+
+ const contractAddress = value.toLowerCase();
+
+ const {
+ iconUrl: image,
+ name: tokenName,
+ symbol,
+ } = erc20TokensByChain?.[variation]?.data?.[contractAddress] ?? {};
+
+ const name = preferContractSymbol && symbol ? symbol : tokenName;
+
+ return { name, image };
+ },
+ );
+}
+
+function useWatchedNFTNames(
+ nameRequests: UseDisplayNameRequest[],
+): (string | undefined)[] {
+ const watchedNftNamesByAddressByChain = useSelector(
+ getNftContractsByAddressByChain,
+ );
+
+ return nameRequests.map(({ type, value, variation }) => {
+ if (type !== NameType.ETHEREUM_ADDRESS) {
+ return undefined;
+ }
+
+ const contractAddress = value.toLowerCase();
+ const watchedNftNamesByAddress = watchedNftNamesByAddressByChain[variation];
+ return watchedNftNamesByAddress?.[contractAddress]?.name;
+ });
+}
+
+function useNFTs(
+ nameRequests: UseDisplayNameRequest[],
+): ({ name?: string; image?: string } | undefined)[] {
+ const requests = nameRequests
+ .filter(({ type }) => type === NameType.ETHEREUM_ADDRESS)
+ .map(({ value, variation }) => ({
+ chainId: variation,
+ contractAddress: value,
+ }));
+
+ const nftCollectionsByAddressByChain = useNftCollectionsMetadata(requests);
+
+ return nameRequests.map(
+ ({ type, value: contractAddress, variation: chainId }) => {
+ if (type !== NameType.ETHEREUM_ADDRESS) {
+ return undefined;
+ }
+
+ const nftCollectionProperties =
+ nftCollectionsByAddressByChain[chainId]?.[
+ contractAddress.toLowerCase()
+ ];
+
+ const isSpam = nftCollectionProperties?.isSpam !== false;
+
+ if (!nftCollectionProperties || isSpam) {
+ return undefined;
+ }
+
+ const { name, image } = nftCollectionProperties;
+
+ return { name, image };
+ },
+ );
+}
+
+function useFirstPartyContractNames(nameRequests: UseDisplayNameRequest[]) {
+ return nameRequests.map(({ type, value, variation }) => {
+ if (type !== NameType.ETHEREUM_ADDRESS) {
+ return undefined;
+ }
+
+ const normalizedContractAddress = value.toLowerCase();
+
+ const contractNames = Object.keys(
+ FIRST_PARTY_CONTRACT_NAMES,
+ ) as EXPERIENCES_TYPE[];
+
+ return contractNames.find((contractName) => {
+ const currentContractAddress =
+ FIRST_PARTY_CONTRACT_NAMES[contractName]?.[variation as Hex];
+
+ return (
+ currentContractAddress?.toLowerCase() === normalizedContractAddress
+ );
+ });
+ });
}
diff --git a/ui/hooks/useFirstPartyContractName.test.ts b/ui/hooks/useFirstPartyContractName.test.ts
deleted file mode 100644
index 14d0cd429e6f..000000000000
--- a/ui/hooks/useFirstPartyContractName.test.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { NameType } from '@metamask/name-controller';
-import { getCurrentChainId } from '../selectors';
-import { CHAIN_IDS } from '../../shared/constants/network';
-import { useFirstPartyContractName } from './useFirstPartyContractName';
-
-jest.mock('react-redux', () => ({
- // TODO: Replace `any` with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- useSelector: (selector: any) => selector(),
-}));
-
-jest.mock('../selectors', () => ({
- getCurrentChainId: jest.fn(),
- getNames: jest.fn(),
-}));
-
-const BRIDGE_NAME_MOCK = 'MetaMask Bridge';
-const BRIDGE_MAINNET_ADDRESS_MOCK =
- '0x0439e60F02a8900a951603950d8D4527f400C3f1';
-const BRIDGE_OPTIMISM_ADDRESS_MOCK =
- '0xB90357f2b86dbfD59c3502215d4060f71DF8ca0e';
-const UNKNOWN_ADDRESS_MOCK = '0xabc123';
-
-describe('useFirstPartyContractName', () => {
- const getCurrentChainIdMock = jest.mocked(getCurrentChainId);
- beforeEach(() => {
- jest.resetAllMocks();
-
- getCurrentChainIdMock.mockReturnValue(CHAIN_IDS.MAINNET);
- });
-
- it('returns null if no name found', () => {
- const name = useFirstPartyContractName(
- UNKNOWN_ADDRESS_MOCK,
- NameType.ETHEREUM_ADDRESS,
- );
-
- expect(name).toBe(null);
- });
-
- it('returns name if found', () => {
- const name = useFirstPartyContractName(
- BRIDGE_MAINNET_ADDRESS_MOCK,
- NameType.ETHEREUM_ADDRESS,
- );
- expect(name).toBe(BRIDGE_NAME_MOCK);
- });
-
- it('uses variation if specified', () => {
- const name = useFirstPartyContractName(
- BRIDGE_OPTIMISM_ADDRESS_MOCK,
- NameType.ETHEREUM_ADDRESS,
- CHAIN_IDS.OPTIMISM,
- );
-
- expect(name).toBe(BRIDGE_NAME_MOCK);
- });
-
- it('returns null if type is not address', () => {
- const alternateType = 'alternateType' as NameType;
-
- const name = useFirstPartyContractName(
- BRIDGE_MAINNET_ADDRESS_MOCK,
- alternateType,
- );
-
- expect(name).toBe(null);
- });
-
- it('normalizes addresses to lowercase', () => {
- const name = useFirstPartyContractName(
- BRIDGE_MAINNET_ADDRESS_MOCK.toUpperCase(),
- NameType.ETHEREUM_ADDRESS,
- );
-
- expect(name).toBe(BRIDGE_NAME_MOCK);
- });
-});
diff --git a/ui/hooks/useFirstPartyContractName.ts b/ui/hooks/useFirstPartyContractName.ts
deleted file mode 100644
index 47468b472955..000000000000
--- a/ui/hooks/useFirstPartyContractName.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { NameType } from '@metamask/name-controller';
-import { useSelector } from 'react-redux';
-import { getCurrentChainId } from '../selectors';
-import {
- EXPERIENCES_TYPE,
- FIRST_PARTY_CONTRACT_NAMES,
-} from '../../shared/constants/first-party-contracts';
-
-export type UseFirstPartyContractNameRequest = {
- value: string;
- type: NameType;
- variation?: string;
-};
-
-export function useFirstPartyContractNames(
- requests: UseFirstPartyContractNameRequest[],
-): (string | null)[] {
- const currentChainId = useSelector(getCurrentChainId);
-
- return requests.map(({ type, value, variation }) => {
- if (type !== NameType.ETHEREUM_ADDRESS) {
- return null;
- }
-
- const chainId = variation ?? currentChainId;
- const normalizedValue = value.toLowerCase();
-
- return (
- Object.keys(FIRST_PARTY_CONTRACT_NAMES).find(
- (name) =>
- FIRST_PARTY_CONTRACT_NAMES[name as EXPERIENCES_TYPE]?.[
- chainId
- ]?.toLowerCase() === normalizedValue,
- ) ?? null
- );
- });
-}
-
-export function useFirstPartyContractName(
- value: string,
- type: NameType,
- variation?: string,
-): string | null {
- return useFirstPartyContractNames([{ value, type, variation }])[0];
-}
diff --git a/ui/hooks/useGasFeeEstimates.js b/ui/hooks/useGasFeeEstimates.js
index 5ad37925054b..abbaf0db0bb9 100644
--- a/ui/hooks/useGasFeeEstimates.js
+++ b/ui/hooks/useGasFeeEstimates.js
@@ -74,9 +74,10 @@ export function useGasFeeEstimates(_networkClientId) {
}, [networkClientId]);
usePolling({
- startPollingByNetworkClientId: gasFeeStartPollingByNetworkClientId,
+ startPolling: (input) =>
+ gasFeeStartPollingByNetworkClientId(input.networkClientId),
stopPollingByPollingToken: gasFeeStopPollingByPollingToken,
- networkClientId,
+ input: { networkClientId },
});
return {
diff --git a/ui/hooks/useGasFeeEstimates.test.js b/ui/hooks/useGasFeeEstimates.test.js
index 0187ac793bbe..dd63e10581d0 100644
--- a/ui/hooks/useGasFeeEstimates.test.js
+++ b/ui/hooks/useGasFeeEstimates.test.js
@@ -8,7 +8,6 @@ import {
getIsNetworkBusyByChainId,
} from '../ducks/metamask/metamask';
import {
- gasFeeStartPollingByNetworkClientId,
gasFeeStopPollingByPollingToken,
getNetworkConfigurationByNetworkClientId,
} from '../store/actions';
@@ -115,9 +114,9 @@ describe('useGasFeeEstimates', () => {
renderHook(() => useGasFeeEstimates());
});
expect(usePolling).toHaveBeenCalledWith({
- startPollingByNetworkClientId: gasFeeStartPollingByNetworkClientId,
+ startPolling: expect.any(Function),
stopPollingByPollingToken: gasFeeStopPollingByPollingToken,
- networkClientId: 'selectedNetworkClientId',
+ input: { networkClientId: 'selectedNetworkClientId' },
});
});
@@ -127,9 +126,9 @@ describe('useGasFeeEstimates', () => {
renderHook(() => useGasFeeEstimates('networkClientId1'));
});
expect(usePolling).toHaveBeenCalledWith({
- startPollingByNetworkClientId: gasFeeStartPollingByNetworkClientId,
+ startPolling: expect.any(Function),
stopPollingByPollingToken: gasFeeStopPollingByPollingToken,
- networkClientId: 'networkClientId1',
+ input: { networkClientId: 'networkClientId1' },
});
});
diff --git a/ui/hooks/useMultichainSelector.ts b/ui/hooks/useMultichainSelector.ts
index 326ac79bf9cd..9bd979df7e7e 100644
--- a/ui/hooks/useMultichainSelector.ts
+++ b/ui/hooks/useMultichainSelector.ts
@@ -11,6 +11,7 @@ export function useMultichainSelector<
) {
return useSelector((state: TState) => {
// We either pass an account or fallback to the currently selected one
+ // @ts-expect-error state types don't match
return selector(state, account || getSelectedInternalAccount(state));
});
}
diff --git a/ui/hooks/useName.test.ts b/ui/hooks/useName.test.ts
index 76bd5dc593ad..f746c4bb6267 100644
--- a/ui/hooks/useName.test.ts
+++ b/ui/hooks/useName.test.ts
@@ -5,7 +5,7 @@ import {
NameOrigin,
NameType,
} from '@metamask/name-controller';
-import { getCurrentChainId, getNames } from '../selectors';
+import { getNames } from '../selectors';
import { useName } from './useName';
jest.mock('react-redux', () => ({
@@ -19,13 +19,14 @@ jest.mock('../selectors', () => ({
getNames: jest.fn(),
}));
-const CHAIN_ID_MOCK = '0x1';
-const CHAIN_ID_2_MOCK = '0x2';
+const VARIATION_MOCK = '0x1';
+const VARIATION_2_MOCK = '0x2';
const VALUE_MOCK = '0xabc123';
const TYPE_MOCK = NameType.ETHEREUM_ADDRESS;
const NAME_MOCK = 'TestName';
const SOURCE_ID_MOCK = 'TestSourceId';
const ORIGIN_MOCK = NameOrigin.API;
+
const PROPOSED_NAMES_MOCK = {
[SOURCE_ID_MOCK]: {
proposedNames: ['TestProposedName', 'TestProposedName2'],
@@ -35,7 +36,6 @@ const PROPOSED_NAMES_MOCK = {
};
describe('useName', () => {
- const getCurrentChainIdMock = jest.mocked(getCurrentChainId);
const getNamesMock =
// TODO: Replace `any` with type
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -43,14 +43,12 @@ describe('useName', () => {
beforeEach(() => {
jest.resetAllMocks();
-
- getCurrentChainIdMock.mockReturnValue(CHAIN_ID_MOCK);
});
it('returns default values if no state', () => {
getNamesMock.mockReturnValue({} as NameControllerState['names']);
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_MOCK);
expect(nameEntry).toStrictEqual({
name: null,
@@ -64,7 +62,7 @@ describe('useName', () => {
getNamesMock.mockReturnValue({
[TYPE_MOCK]: {
[VALUE_MOCK]: {
- [CHAIN_ID_2_MOCK]: {
+ [VARIATION_2_MOCK]: {
name: NAME_MOCK,
proposedNames: PROPOSED_NAMES_MOCK,
sourceId: SOURCE_ID_MOCK,
@@ -74,7 +72,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_MOCK);
expect(nameEntry).toStrictEqual({
name: null,
@@ -88,7 +86,7 @@ describe('useName', () => {
getNamesMock.mockReturnValue({
[TYPE_MOCK]: {
[VALUE_MOCK]: {
- [CHAIN_ID_MOCK]: {
+ [VARIATION_MOCK]: {
name: NAME_MOCK,
proposedNames: PROPOSED_NAMES_MOCK,
sourceId: SOURCE_ID_MOCK,
@@ -98,7 +96,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_MOCK);
expect(nameEntry).toStrictEqual({
name: NAME_MOCK,
@@ -112,7 +110,7 @@ describe('useName', () => {
getNamesMock.mockReturnValue({
[TYPE_MOCK]: {
[VALUE_MOCK]: {
- [CHAIN_ID_2_MOCK]: {
+ [VARIATION_2_MOCK]: {
name: NAME_MOCK,
proposedNames: PROPOSED_NAMES_MOCK,
sourceId: SOURCE_ID_MOCK,
@@ -122,7 +120,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, CHAIN_ID_2_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_2_MOCK);
expect(nameEntry).toStrictEqual({
name: NAME_MOCK,
@@ -147,7 +145,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, CHAIN_ID_2_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_2_MOCK);
expect(nameEntry).toStrictEqual({
name: NAME_MOCK,
@@ -161,7 +159,7 @@ describe('useName', () => {
getNamesMock.mockReturnValue({
[TYPE_MOCK]: {
[VALUE_MOCK]: {
- [CHAIN_ID_2_MOCK]: {
+ [VARIATION_2_MOCK]: {
name: null,
proposedNames: PROPOSED_NAMES_MOCK,
sourceId: null,
@@ -177,7 +175,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, CHAIN_ID_2_MOCK);
+ const nameEntry = useName(VALUE_MOCK, TYPE_MOCK, VARIATION_2_MOCK);
expect(nameEntry).toStrictEqual({
name: NAME_MOCK,
@@ -188,37 +186,11 @@ describe('useName', () => {
});
});
- it('uses empty string as variation if not specified and type is not address', () => {
- const alternateType = 'alternateType' as NameType;
-
- getNamesMock.mockReturnValue({
- [alternateType]: {
- [VALUE_MOCK]: {
- '': {
- name: NAME_MOCK,
- proposedNames: PROPOSED_NAMES_MOCK,
- sourceId: SOURCE_ID_MOCK,
- origin: ORIGIN_MOCK,
- },
- },
- },
- });
-
- const nameEntry = useName(VALUE_MOCK, alternateType);
-
- expect(nameEntry).toStrictEqual({
- name: NAME_MOCK,
- sourceId: SOURCE_ID_MOCK,
- proposedNames: PROPOSED_NAMES_MOCK,
- origin: ORIGIN_MOCK,
- });
- });
-
it('normalizes addresses to lowercase', () => {
getNamesMock.mockReturnValue({
[TYPE_MOCK]: {
[VALUE_MOCK]: {
- [CHAIN_ID_MOCK]: {
+ [VARIATION_MOCK]: {
name: NAME_MOCK,
proposedNames: PROPOSED_NAMES_MOCK,
sourceId: SOURCE_ID_MOCK,
@@ -228,7 +200,7 @@ describe('useName', () => {
},
});
- const nameEntry = useName('0xAbC123', TYPE_MOCK);
+ const nameEntry = useName('0xAbC123', TYPE_MOCK, VARIATION_MOCK);
expect(nameEntry).toStrictEqual({
name: NAME_MOCK,
diff --git a/ui/hooks/useName.ts b/ui/hooks/useName.ts
index 3af9b0457f79..dd587e81abf6 100644
--- a/ui/hooks/useName.ts
+++ b/ui/hooks/useName.ts
@@ -5,32 +5,29 @@ import {
} from '@metamask/name-controller';
import { useSelector } from 'react-redux';
import { isEqual } from 'lodash';
-import { getCurrentChainId, getNames } from '../selectors';
+import { getNames } from '../selectors';
export type UseNameRequest = {
value: string;
type: NameType;
- variation?: string;
+ variation: string;
};
export function useName(
value: string,
type: NameType,
- variation?: string,
+ variation: string,
): NameEntry {
return useNames([{ value, type, variation }])[0];
}
export function useNames(requests: UseNameRequest[]): NameEntry[] {
const names = useSelector(getNames, isEqual);
- const chainId = useSelector(getCurrentChainId);
return requests.map(({ value, type, variation }) => {
const normalizedValue = normalizeValue(value, type);
- const typeVariationKey = getVariationKey(type, chainId);
- const variationKey = variation ?? typeVariationKey;
const variationsToNameEntries = names[type]?.[normalizedValue] ?? {};
- const variationEntry = variationsToNameEntries[variationKey];
+ const variationEntry = variationsToNameEntries[variation];
const fallbackEntry = variationsToNameEntries[FALLBACK_VARIATION];
const entry =
@@ -63,13 +60,3 @@ function normalizeValue(value: string, type: string): string {
return value;
}
}
-
-function getVariationKey(type: string, chainId: string): string {
- switch (type) {
- case NameType.ETHEREUM_ADDRESS:
- return chainId;
-
- default:
- return '';
- }
-}
diff --git a/ui/hooks/useNftCollectionsMetadata.test.ts b/ui/hooks/useNftCollectionsMetadata.test.ts
index 4897e449e6ad..e1e2b6745ad1 100644
--- a/ui/hooks/useNftCollectionsMetadata.test.ts
+++ b/ui/hooks/useNftCollectionsMetadata.test.ts
@@ -1,6 +1,5 @@
import { renderHook } from '@testing-library/react-hooks';
import { TokenStandard } from '../../shared/constants/transaction';
-import { getCurrentChainId } from '../selectors';
import {
getNFTContractInfo,
getTokenStandardAndDetails,
@@ -42,7 +41,6 @@ const ERC_721_COLLECTION_2_MOCK = {
};
describe('useNftCollectionsMetadata', () => {
- const mockGetCurrentChainId = jest.mocked(getCurrentChainId);
const mockGetNFTContractInfo = jest.mocked(getNFTContractInfo);
const mockGetTokenStandardAndDetails = jest.mocked(
getTokenStandardAndDetails,
@@ -50,7 +48,6 @@ describe('useNftCollectionsMetadata', () => {
beforeEach(() => {
jest.resetAllMocks();
- mockGetCurrentChainId.mockReturnValue(CHAIN_ID_MOCK);
mockGetNFTContractInfo.mockResolvedValue({
collections: [ERC_721_COLLECTION_1_MOCK, ERC_721_COLLECTION_2_MOCK],
});
@@ -67,10 +64,12 @@ describe('useNftCollectionsMetadata', () => {
const { result, waitForNextUpdate } = renderHook(() =>
useNftCollectionsMetadata([
{
- value: ERC_721_ADDRESS_1,
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: ERC_721_ADDRESS_1,
},
{
- value: ERC_721_ADDRESS_2,
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: ERC_721_ADDRESS_2,
},
]),
);
@@ -79,8 +78,10 @@ describe('useNftCollectionsMetadata', () => {
expect(mockGetNFTContractInfo).toHaveBeenCalledTimes(1);
expect(result.current).toStrictEqual({
- [ERC_721_ADDRESS_1.toLowerCase()]: ERC_721_COLLECTION_1_MOCK,
- [ERC_721_ADDRESS_2.toLowerCase()]: ERC_721_COLLECTION_2_MOCK,
+ [CHAIN_ID_MOCK]: {
+ [ERC_721_ADDRESS_1.toLowerCase()]: ERC_721_COLLECTION_1_MOCK,
+ [ERC_721_ADDRESS_2.toLowerCase()]: ERC_721_COLLECTION_2_MOCK,
+ },
});
});
@@ -99,7 +100,8 @@ describe('useNftCollectionsMetadata', () => {
renderHook(() =>
useNftCollectionsMetadata([
{
- value: '0xERC20Address',
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: '0xERC20Address',
},
]),
);
@@ -114,7 +116,8 @@ describe('useNftCollectionsMetadata', () => {
renderHook(() =>
useNftCollectionsMetadata([
{
- value: '0xERC20Address',
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: '0xERC20Address',
},
]),
);
@@ -126,10 +129,12 @@ describe('useNftCollectionsMetadata', () => {
const { waitForNextUpdate, rerender } = renderHook(() =>
useNftCollectionsMetadata([
{
- value: ERC_721_ADDRESS_1,
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: ERC_721_ADDRESS_1,
},
{
- value: ERC_721_ADDRESS_2,
+ chainId: CHAIN_ID_MOCK,
+ contractAddress: ERC_721_ADDRESS_2,
},
]),
);
diff --git a/ui/hooks/useNftCollectionsMetadata.ts b/ui/hooks/useNftCollectionsMetadata.ts
index 641e0fb25dcd..e71216e254c9 100644
--- a/ui/hooks/useNftCollectionsMetadata.ts
+++ b/ui/hooks/useNftCollectionsMetadata.ts
@@ -1,9 +1,5 @@
-import { useMemo } from 'react';
-import { useSelector } from 'react-redux';
import { Collection } from '@metamask/assets-controllers';
-import type { Hex } from '@metamask/utils';
import { TokenStandard } from '../../shared/constants/transaction';
-import { getCurrentChainId } from '../selectors';
import {
getNFTContractInfo,
getTokenStandardAndDetails,
@@ -11,28 +7,62 @@ import {
import { useAsyncResult } from './useAsyncResult';
export type UseNftCollectionsMetadataRequest = {
- value: string;
- chainId?: string;
-};
-
-type CollectionsData = {
- [key: string]: Collection;
+ chainId: string;
+ contractAddress: string;
};
// For now, we only support ERC721 tokens
const SUPPORTED_NFT_TOKEN_STANDARDS = [TokenStandard.ERC721];
-async function fetchCollections(
- memoisedContracts: string[],
+export function useNftCollectionsMetadata(
+ requests: UseNftCollectionsMetadataRequest[],
+): Record> {
+ const { value: collectionsMetadata } = useAsyncResult(
+ () => fetchCollections(requests),
+ [JSON.stringify(requests)],
+ );
+
+ return collectionsMetadata ?? {};
+}
+
+async function fetchCollections(requests: UseNftCollectionsMetadataRequest[]) {
+ const valuesByChainId = requests.reduce>(
+ (acc, { chainId, contractAddress }) => {
+ acc[chainId] = [...(acc[chainId] ?? []), contractAddress.toLowerCase()];
+ return acc;
+ },
+ {},
+ );
+
+ const chainIds = Object.keys(valuesByChainId);
+
+ const responses = await Promise.all(
+ chainIds.map((chainId) => {
+ const contractAddresses = valuesByChainId[chainId];
+ return fetchCollectionsForChain(contractAddresses, chainId);
+ }),
+ );
+
+ return chainIds.reduce>>(
+ (acc, chainId, index) => {
+ acc[chainId] = responses[index];
+ return acc;
+ },
+ {},
+ );
+}
+
+async function fetchCollectionsForChain(
+ contractAddresses: string[],
chainId: string,
-): Promise {
+) {
const contractStandardsResponses = await Promise.all(
- memoisedContracts.map((contractAddress) =>
+ contractAddresses.map((contractAddress) =>
getTokenStandardAndDetails(contractAddress, chainId),
),
);
- const supportedNFTContracts = memoisedContracts.filter(
+ const supportedNFTContracts = contractAddresses.filter(
(_contractAddress, index) =>
SUPPORTED_NFT_TOKEN_STANDARDS.includes(
contractStandardsResponses[index].standard as TokenStandard,
@@ -48,37 +78,16 @@ async function fetchCollections(
chainId,
);
- const collectionsData: CollectionsData = collectionsResult.collections.reduce(
- (acc: CollectionsData, collection, index) => {
- acc[supportedNFTContracts[index]] = {
- name: collection?.name,
- image: collection?.image,
- isSpam: collection?.isSpam,
- };
- return acc;
- },
- {},
- );
+ const collectionsData = collectionsResult.collections.reduce<
+ Record
+ >((acc, collection, index) => {
+ acc[supportedNFTContracts[index]] = {
+ name: collection?.name,
+ image: collection?.image,
+ isSpam: collection?.isSpam,
+ };
+ return acc;
+ }, {});
return collectionsData;
}
-
-export function useNftCollectionsMetadata(
- requests: UseNftCollectionsMetadataRequest[],
- providedChainId?: Hex,
-) {
- const chainId = useSelector(getCurrentChainId) || providedChainId;
-
- const memoisedContracts = useMemo(() => {
- return requests
- .filter(({ value }) => value)
- .map(({ value }) => value.toLowerCase());
- }, [requests]);
-
- const { value: collectionsMetadata } = useAsyncResult(
- () => fetchCollections(memoisedContracts, chainId),
- [JSON.stringify(memoisedContracts), chainId],
- );
-
- return collectionsMetadata || {};
-}
diff --git a/ui/hooks/usePolling.test.js b/ui/hooks/usePolling.test.js
index 9250257d3cbc..a556bb86be54 100644
--- a/ui/hooks/usePolling.test.js
+++ b/ui/hooks/usePolling.test.js
@@ -4,13 +4,12 @@ import usePolling from './usePolling';
describe('usePolling', () => {
// eslint-disable-next-line jest/no-done-callback
- it('calls startPollingByNetworkClientId and callback option args with polling token when component instantiating the hook mounts', (done) => {
+ it('calls startPolling and calls back with polling token when component instantiating the hook mounts', (done) => {
const mockStart = jest.fn().mockImplementation(() => {
return Promise.resolve('pollingToken');
});
const mockStop = jest.fn();
const networkClientId = 'mainnet';
- const options = {};
const mockState = {
metamask: {},
};
@@ -18,17 +17,16 @@ describe('usePolling', () => {
renderHookWithProvider(() => {
usePolling({
callback: (pollingToken) => {
- expect(mockStart).toHaveBeenCalledWith(networkClientId, options);
+ expect(mockStart).toHaveBeenCalledWith({ networkClientId });
expect(pollingToken).toBeDefined();
done();
return (_pollingToken) => {
// noop
};
},
- startPollingByNetworkClientId: mockStart,
+ startPolling: mockStart,
stopPollingByPollingToken: mockStop,
- networkClientId,
- options,
+ input: { networkClientId },
});
}, mockState);
});
@@ -39,7 +37,6 @@ describe('usePolling', () => {
});
const mockStop = jest.fn();
const networkClientId = 'mainnet';
- const options = {};
const mockState = {
metamask: {},
};
@@ -54,10 +51,9 @@ describe('usePolling', () => {
done();
};
},
- startPollingByNetworkClientId: mockStart,
+ startPolling: mockStart,
stopPollingByPollingToken: mockStop,
- networkClientId,
- options,
+ input: { networkClientId },
}),
mockState,
);
diff --git a/ui/hooks/usePolling.ts b/ui/hooks/usePolling.ts
index 1a9d6b1f576e..613e70cf17b5 100644
--- a/ui/hooks/usePolling.ts
+++ b/ui/hooks/usePolling.ts
@@ -1,22 +1,16 @@
import { useEffect, useRef } from 'react';
-type UsePollingOptions = {
+type UsePollingOptions = {
callback?: (pollingToken: string) => (pollingToken: string) => void;
- startPollingByNetworkClientId: (
- networkClientId: string,
- // TODO: Replace `any` with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- options: any,
- ) => Promise;
+ startPolling: (input: PollingInput) => Promise;
stopPollingByPollingToken: (pollingToken: string) => void;
- networkClientId: string;
- // TODO: Replace `any` with type
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- options?: any;
+ input: PollingInput;
enabled?: boolean;
};
-const usePolling = (usePollingOptions: UsePollingOptions) => {
+const usePolling = (
+ usePollingOptions: UsePollingOptions,
+) => {
const pollTokenRef = useRef(null);
const cleanupRef = useRef void)>(null);
let isMounted = false;
@@ -38,10 +32,7 @@ const usePolling = (usePollingOptions: UsePollingOptions) => {
// Start polling when the component mounts
usePollingOptions
- .startPollingByNetworkClientId(
- usePollingOptions.networkClientId,
- usePollingOptions.options,
- )
+ .startPolling(usePollingOptions.input)
.then((pollToken) => {
pollTokenRef.current = pollToken;
cleanupRef.current = usePollingOptions.callback?.(pollToken) || null;
@@ -56,12 +47,7 @@ const usePolling = (usePollingOptions: UsePollingOptions) => {
cleanup();
};
}, [
- usePollingOptions.networkClientId,
- usePollingOptions.options &&
- JSON.stringify(
- usePollingOptions.options,
- Object.keys(usePollingOptions.options).sort(),
- ),
+ usePollingOptions.input && JSON.stringify(usePollingOptions.input),
usePollingOptions.enabled,
]);
};
diff --git a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
index 95828e3e250e..79400367de13 100644
--- a/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
+++ b/ui/pages/asset/components/__snapshots__/asset-page.test.tsx.snap
@@ -268,17 +268,17 @@ exports[`AssetPage should render a native asset 1`] = `
- Start your journey with ETH
+ Tips for using a wallet
- Get started with web3 by adding some ETH to your wallet.
+ Adding tokens unlocks more ways to use web3.
- Start your journey with ETH
+ Tips for using a wallet
- Get started with web3 by adding some ETH to your wallet.
+ Adding tokens unlocks more ways to use web3.
- Start your journey with ETH
+ Tips for using a wallet
- Get started with web3 by adding some ETH to your wallet.
+ Adding tokens unlocks more ways to use web3.
{
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
describe('fetchBridgeFeatureFlags', () => {
it('should fetch bridge feature flags successfully', async () => {
const mockResponse = {
+ 'extension-config': {
+ refreshRate: 3,
+ maxRefreshCount: 1,
+ },
'extension-support': true,
'src-network-allowlist': [1, 10, 59144, 120],
'dest-network-allowlist': [1, 137, 59144, 11111],
@@ -28,6 +43,10 @@ describe('Bridge utils', () => {
});
expect(result).toStrictEqual({
+ extensionConfig: {
+ maxRefreshCount: 1,
+ refreshRate: 3,
+ },
extensionSupport: true,
srcNetworkAllowlist: [
CHAIN_IDS.MAINNET,
@@ -46,8 +65,10 @@ describe('Bridge utils', () => {
it('should use fallback bridge feature flags if response is unexpected', async () => {
const mockResponse = {
- flag1: true,
- flag2: false,
+ 'extension-support': 25,
+ 'src-network-allowlist': ['a', 'b', 1],
+ a: 'b',
+ 'dest-network-allowlist': [1, 137, 59144, 11111],
};
(fetchWithCache as jest.Mock).mockResolvedValue(mockResponse);
@@ -65,6 +86,10 @@ describe('Bridge utils', () => {
});
expect(result).toStrictEqual({
+ extensionConfig: {
+ maxRefreshCount: 5,
+ refreshRate: 30000,
+ },
extensionSupport: false,
srcNetworkAllowlist: [],
destNetworkAllowlist: [],
@@ -141,4 +166,113 @@ describe('Bridge utils', () => {
await expect(fetchBridgeTokens('0xa')).rejects.toThrowError(mockError);
});
});
+
+ describe('fetchBridgeQuotes', () => {
+ it('should fetch bridge quotes successfully, no approvals', async () => {
+ (fetchWithCache as jest.Mock).mockResolvedValue(
+ mockBridgeQuotesNativeErc20,
+ );
+
+ const result = await fetchBridgeQuotes({
+ walletAddress: '0x123',
+ srcChainId: 1,
+ destChainId: 10,
+ srcTokenAddress: zeroAddress(),
+ destTokenAddress: zeroAddress(),
+ srcTokenAmount: '20000',
+ slippage: 0.5,
+ });
+
+ expect(fetchWithCache).toHaveBeenCalledWith({
+ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false',
+ fetchOptions: {
+ method: 'GET',
+ headers: { 'X-Client-Id': 'extension' },
+ },
+ cacheOptions: { cacheRefreshTime: 0 },
+ functionName: 'fetchBridgeQuotes',
+ });
+
+ expect(result).toStrictEqual(mockBridgeQuotesNativeErc20);
+ });
+
+ it('should fetch bridge quotes successfully, with approvals', async () => {
+ (fetchWithCache as jest.Mock).mockResolvedValue([
+ ...mockBridgeQuotesErc20Erc20,
+ { ...mockBridgeQuotesErc20Erc20[0], approval: null },
+ { ...mockBridgeQuotesErc20Erc20[0], trade: null },
+ ]);
+
+ const result = await fetchBridgeQuotes({
+ walletAddress: '0x123',
+ srcChainId: 1,
+ destChainId: 10,
+ srcTokenAddress: zeroAddress(),
+ destTokenAddress: zeroAddress(),
+ srcTokenAmount: '20000',
+ slippage: 0.5,
+ });
+
+ expect(fetchWithCache).toHaveBeenCalledWith({
+ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false',
+ fetchOptions: {
+ method: 'GET',
+ headers: { 'X-Client-Id': 'extension' },
+ },
+ cacheOptions: { cacheRefreshTime: 0 },
+ functionName: 'fetchBridgeQuotes',
+ });
+
+ expect(result).toStrictEqual(mockBridgeQuotesErc20Erc20);
+ });
+
+ it('should filter out malformed bridge quotes', async () => {
+ (fetchWithCache as jest.Mock).mockResolvedValue([
+ ...mockBridgeQuotesErc20Erc20,
+ ...mockBridgeQuotesErc20Erc20.map(
+ ({ quote, ...restOfQuote }) => restOfQuote,
+ ),
+ {
+ ...mockBridgeQuotesErc20Erc20[0],
+ quote: {
+ srcAsset: {
+ ...mockBridgeQuotesErc20Erc20[0].quote.srcAsset,
+ decimals: undefined,
+ },
+ },
+ },
+ {
+ ...mockBridgeQuotesErc20Erc20[1],
+ quote: {
+ srcAsset: {
+ ...mockBridgeQuotesErc20Erc20[1].quote.destAsset,
+ address: undefined,
+ },
+ },
+ },
+ ]);
+
+ const result = await fetchBridgeQuotes({
+ walletAddress: '0x123',
+ srcChainId: 1,
+ destChainId: 10,
+ srcTokenAddress: zeroAddress(),
+ destTokenAddress: zeroAddress(),
+ srcTokenAmount: '20000',
+ slippage: 0.5,
+ });
+
+ expect(fetchWithCache).toHaveBeenCalledWith({
+ url: 'https://bridge.api.cx.metamask.io/getQuote?walletAddress=0x123&srcChainId=1&destChainId=10&srcTokenAddress=0x0000000000000000000000000000000000000000&destTokenAddress=0x0000000000000000000000000000000000000000&srcTokenAmount=20000&slippage=0.5&insufficientBal=false&resetApproval=false',
+ fetchOptions: {
+ method: 'GET',
+ headers: { 'X-Client-Id': 'extension' },
+ },
+ cacheOptions: { cacheRefreshTime: 0 },
+ functionName: 'fetchBridgeQuotes',
+ });
+
+ expect(result).toStrictEqual(mockBridgeQuotesErc20Erc20);
+ });
+ });
});
diff --git a/ui/pages/bridge/bridge.util.ts b/ui/pages/bridge/bridge.util.ts
index 915a933e7c02..f154b7e62b19 100644
--- a/ui/pages/bridge/bridge.util.ts
+++ b/ui/pages/bridge/bridge.util.ts
@@ -11,7 +11,6 @@ import {
} from '../../../shared/constants/bridge';
import { MINUTE } from '../../../shared/constants/time';
import fetchWithCache from '../../../shared/lib/fetch-with-cache';
-import { validateData } from '../../../shared/lib/swaps-utils';
import {
decimalToHex,
hexToDecimal,
@@ -20,43 +19,37 @@ import {
SWAPS_CHAINID_DEFAULT_TOKEN_MAP,
SwapsTokenObject,
} from '../../../shared/constants/swaps';
-import { TOKEN_VALIDATORS } from '../swaps/swaps.util';
import {
isSwapsDefaultTokenAddress,
isSwapsDefaultTokenSymbol,
} from '../../../shared/modules/swaps.utils';
+// TODO: Remove restricted import
+// eslint-disable-next-line import/no-restricted-paths
+import { REFRESH_INTERVAL_MS } from '../../../app/scripts/controllers/bridge/constants';
+import {
+ BridgeAsset,
+ BridgeFlag,
+ FeatureFlagResponse,
+ FeeData,
+ FeeType,
+ Quote,
+ QuoteRequest,
+ QuoteResponse,
+ TxData,
+} from './types';
+import {
+ FEATURE_FLAG_VALIDATORS,
+ QUOTE_VALIDATORS,
+ TX_DATA_VALIDATORS,
+ TOKEN_VALIDATORS,
+ validateResponse,
+ QUOTE_RESPONSE_VALIDATORS,
+ FEE_DATA_VALIDATORS,
+} from './utils/validators';
const CLIENT_ID_HEADER = { 'X-Client-Id': BRIDGE_CLIENT_ID };
const CACHE_REFRESH_TEN_MINUTES = 10 * MINUTE;
-// Types copied from Metabridge API
-enum BridgeFlag {
- EXTENSION_SUPPORT = 'extension-support',
- NETWORK_SRC_ALLOWLIST = 'src-network-allowlist',
- NETWORK_DEST_ALLOWLIST = 'dest-network-allowlist',
-}
-
-export type FeatureFlagResponse = {
- [BridgeFlag.EXTENSION_SUPPORT]: boolean;
- [BridgeFlag.NETWORK_SRC_ALLOWLIST]: number[];
- [BridgeFlag.NETWORK_DEST_ALLOWLIST]: number[];
-};
-// End of copied types
-
-type Validator
= {
- property: keyof ExpectedResponse | string;
- type: string;
- validator: (value: DataToValidate) => boolean;
-};
-
-const validateResponse = (
- validators: Validator[],
- data: unknown,
- urlUsed: string,
-): data is ExpectedResponse => {
- return validateData(validators, data, urlUsed);
-};
-
export async function fetchBridgeFeatureFlags(): Promise {
const url = `${BRIDGE_API_BASE_URL}/getAllFeatureFlags`;
const rawFeatureFlags = await fetchWithCache({
@@ -67,35 +60,15 @@ export async function fetchBridgeFeatureFlags(): Promise {
});
if (
- validateResponse(
- [
- {
- property: BridgeFlag.EXTENSION_SUPPORT,
- type: 'boolean',
- validator: (v) => typeof v === 'boolean',
- },
- {
- property: BridgeFlag.NETWORK_SRC_ALLOWLIST,
- type: 'object',
- validator: (v): v is number[] =>
- Object.values(v as { [s: string]: unknown }).every(
- (i) => typeof i === 'number',
- ),
- },
- {
- property: BridgeFlag.NETWORK_DEST_ALLOWLIST,
- type: 'object',
- validator: (v): v is number[] =>
- Object.values(v as { [s: string]: unknown }).every(
- (i) => typeof i === 'number',
- ),
- },
- ],
+ validateResponse(
+ FEATURE_FLAG_VALIDATORS,
rawFeatureFlags,
url,
)
) {
return {
+ [BridgeFeatureFlagsKey.EXTENSION_CONFIG]:
+ rawFeatureFlags[BridgeFlag.EXTENSION_CONFIG],
[BridgeFeatureFlagsKey.EXTENSION_SUPPORT]:
rawFeatureFlags[BridgeFlag.EXTENSION_SUPPORT],
[BridgeFeatureFlagsKey.NETWORK_SRC_ALLOWLIST]: rawFeatureFlags[
@@ -108,6 +81,10 @@ export async function fetchBridgeFeatureFlags(): Promise {
}
return {
+ [BridgeFeatureFlagsKey.EXTENSION_CONFIG]: {
+ refreshRate: REFRESH_INTERVAL_MS,
+ maxRefreshCount: 5,
+ },
// TODO set default to true once bridging is live
[BridgeFeatureFlagsKey.EXTENSION_SUPPORT]: false,
// TODO set default to ALLOWED_BRIDGE_CHAIN_IDS once bridging is live
@@ -142,13 +119,9 @@ export async function fetchBridgeTokens(
transformedTokens[nativeToken.address] = nativeToken;
}
- tokens.forEach((token: SwapsTokenObject) => {
+ tokens.forEach((token: unknown) => {
if (
- validateResponse(
- TOKEN_VALIDATORS,
- token,
- url,
- ) &&
+ validateResponse(TOKEN_VALIDATORS, token, url) &&
!(
isSwapsDefaultTokenSymbol(token.symbol, chainId) ||
isSwapsDefaultTokenAddress(token.address, chainId)
@@ -159,3 +132,51 @@ export async function fetchBridgeTokens(
});
return transformedTokens;
}
+
+// Returns a list of bridge tx quotes
+export async function fetchBridgeQuotes(
+ request: QuoteRequest,
+): Promise {
+ const queryParams = new URLSearchParams({
+ walletAddress: request.walletAddress,
+ srcChainId: request.srcChainId.toString(),
+ destChainId: request.destChainId.toString(),
+ srcTokenAddress: request.srcTokenAddress,
+ destTokenAddress: request.destTokenAddress,
+ srcTokenAmount: request.srcTokenAmount,
+ slippage: request.slippage.toString(),
+ insufficientBal: request.insufficientBal ? 'true' : 'false',
+ resetApproval: request.resetApproval ? 'true' : 'false',
+ });
+ const url = `${BRIDGE_API_BASE_URL}/getQuote?${queryParams}`;
+ const quotes = await fetchWithCache({
+ url,
+ fetchOptions: { method: 'GET', headers: CLIENT_ID_HEADER },
+ cacheOptions: { cacheRefreshTime: 0 },
+ functionName: 'fetchBridgeQuotes',
+ });
+
+ const filteredQuotes = quotes.filter((quoteResponse: QuoteResponse) => {
+ const { quote, approval, trade } = quoteResponse;
+ return (
+ validateResponse(
+ QUOTE_RESPONSE_VALIDATORS,
+ quoteResponse,
+ url,
+ ) &&
+ validateResponse(QUOTE_VALIDATORS, quote, url) &&
+ validateResponse(TOKEN_VALIDATORS, quote.srcAsset, url) &&
+ validateResponse(TOKEN_VALIDATORS, quote.destAsset, url) &&
+ validateResponse(TX_DATA_VALIDATORS, trade, url) &&
+ validateResponse(
+ FEE_DATA_VALIDATORS,
+ quote.feeData[FeeType.METABRIDGE],
+ url,
+ ) &&
+ (approval
+ ? validateResponse(TX_DATA_VALIDATORS, approval, url)
+ : true)
+ );
+ });
+ return filteredQuotes;
+}
diff --git a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
index b406cafe0941..4284c1893d7c 100644
--- a/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
+++ b/ui/pages/bridge/prepare/__snapshots__/prepare-bridge-page.test.tsx.snap
@@ -107,6 +107,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
>
$0.00
@@ -191,6 +192,7 @@ exports[`PrepareBridgePage should render the component, with initial state 1`] =
>
$0.00
@@ -316,6 +318,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
>
$0.00
@@ -444,6 +447,7 @@ exports[`PrepareBridgePage should render the component, with inputs set 1`] = `
>
$0.00
diff --git a/ui/pages/bridge/prepare/prepare-bridge-page.tsx b/ui/pages/bridge/prepare/prepare-bridge-page.tsx
index 2fdb11289c5b..b0907f83dab7 100644
--- a/ui/pages/bridge/prepare/prepare-bridge-page.tsx
+++ b/ui/pages/bridge/prepare/prepare-bridge-page.tsx
@@ -1,13 +1,15 @@
-import React, { useState } from 'react';
+import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import classnames from 'classnames';
+import { debounce } from 'lodash';
import {
setFromChain,
setFromToken,
setFromTokenInputValue,
setToChain,
+ setToChainId,
setToToken,
- switchToAndFromTokens,
+ updateQuoteRequestParams,
} from '../../../ducks/bridge/actions';
import {
getFromAmount,
@@ -28,11 +30,14 @@ import {
ButtonIcon,
IconName,
} from '../../../components/component-library';
+import { BlockSize } from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { TokenBucketPriority } from '../../../../shared/constants/swaps';
import { useTokensWithFiltering } from '../../../hooks/useTokensWithFiltering';
import { setActiveNetwork } from '../../../store/actions';
-import { BlockSize } from '../../../helpers/constants/design-system';
+import { hexToDecimal } from '../../../../shared/modules/conversion.utils';
+import { QuoteRequest } from '../types';
+import { calcTokenValue } from '../../../../shared/lib/swaps-utils';
import { BridgeInputGroup } from './bridge-input-group';
const PrepareBridgePage = () => {
@@ -71,6 +76,36 @@ const PrepareBridgePage = () => {
const [rotateSwitchTokens, setRotateSwitchTokens] = useState(false);
+ const quoteParams = useMemo(
+ () => ({
+ srcTokenAddress: fromToken?.address,
+ destTokenAddress: toToken?.address || undefined,
+ srcTokenAmount:
+ fromAmount && fromAmount !== '' && fromToken?.decimals
+ ? calcTokenValue(fromAmount, fromToken.decimals).toString()
+ : undefined,
+ srcChainId: fromChain?.chainId
+ ? Number(hexToDecimal(fromChain.chainId))
+ : undefined,
+ destChainId: toChain?.chainId
+ ? Number(hexToDecimal(toChain.chainId))
+ : undefined,
+ }),
+ [fromToken, toToken, fromChain?.chainId, toChain?.chainId, fromAmount],
+ );
+
+ const debouncedUpdateQuoteRequestInController = useCallback(
+ debounce(
+ (p: Partial) => dispatch(updateQuoteRequestParams(p)),
+ 300,
+ ),
+ [],
+ );
+
+ useEffect(() => {
+ debouncedUpdateQuoteRequestInController(quoteParams);
+ }, Object.values(quoteParams));
+
return (
@@ -81,7 +116,10 @@ const PrepareBridgePage = () => {
onAmountChange={(e) => {
dispatch(setFromTokenInputValue(e));
}}
- onAssetChange={(token) => dispatch(setFromToken(token))}
+ onAssetChange={(token) => {
+ dispatch(setFromToken(token));
+ dispatch(setFromTokenInputValue(null));
+ }}
networkProps={{
network: fromChain,
networks: fromChains,
@@ -94,6 +132,8 @@ const PrepareBridgePage = () => {
),
);
dispatch(setFromChain(networkConfig.chainId));
+ dispatch(setFromToken(null));
+ dispatch(setFromTokenInputValue(null));
},
}}
customTokenListGenerator={
@@ -121,12 +161,18 @@ const PrepareBridgePage = () => {
onClick={() => {
setRotateSwitchTokens(!rotateSwitchTokens);
const toChainClientId =
- toChain?.defaultRpcEndpointIndex && toChain?.rpcEndpoints
- ? toChain.rpcEndpoints?.[toChain.defaultRpcEndpointIndex]
+ toChain?.defaultRpcEndpointIndex !== undefined &&
+ toChain?.rpcEndpoints
+ ? toChain.rpcEndpoints[toChain.defaultRpcEndpointIndex]
.networkClientId
: undefined;
toChainClientId && dispatch(setActiveNetwork(toChainClientId));
- dispatch(switchToAndFromTokens({ fromChain }));
+ toChain && dispatch(setFromChain(toChain.chainId));
+ dispatch(setFromToken(toToken));
+ dispatch(setFromTokenInputValue(null));
+ fromChain?.chainId && dispatch(setToChain(fromChain.chainId));
+ fromChain?.chainId && dispatch(setToChainId(fromChain.chainId));
+ dispatch(setToToken(fromToken));
}}
/>
@@ -140,6 +186,7 @@ const PrepareBridgePage = () => {
network: toChain,
networks: toChains,
onNetworkChange: (networkConfig) => {
+ dispatch(setToChainId(networkConfig.chainId));
dispatch(setToChain(networkConfig.chainId));
},
}}
diff --git a/ui/pages/bridge/types.ts b/ui/pages/bridge/types.ts
new file mode 100644
index 000000000000..5d001e7ef7fc
--- /dev/null
+++ b/ui/pages/bridge/types.ts
@@ -0,0 +1,119 @@
+// Types copied from Metabridge API
+export enum BridgeFlag {
+ EXTENSION_CONFIG = 'extension-config',
+ EXTENSION_SUPPORT = 'extension-support',
+ NETWORK_SRC_ALLOWLIST = 'src-network-allowlist',
+ NETWORK_DEST_ALLOWLIST = 'dest-network-allowlist',
+}
+
+export type FeatureFlagResponse = {
+ [BridgeFlag.EXTENSION_CONFIG]: {
+ refreshRate: number;
+ maxRefreshCount: number;
+ };
+ [BridgeFlag.EXTENSION_SUPPORT]: boolean;
+ [BridgeFlag.NETWORK_SRC_ALLOWLIST]: number[];
+ [BridgeFlag.NETWORK_DEST_ALLOWLIST]: number[];
+};
+
+export type BridgeAsset = {
+ chainId: ChainId;
+ address: string;
+ symbol: string;
+ name: string;
+ decimals: number;
+ icon?: string;
+};
+
+export type QuoteRequest = {
+ walletAddress: string;
+ destWalletAddress?: string;
+ srcChainId: ChainId;
+ destChainId: ChainId;
+ srcTokenAddress: string;
+ destTokenAddress: string;
+ srcTokenAmount: string;
+ slippage: number;
+ aggIds?: string[];
+ bridgeIds?: string[];
+ insufficientBal?: boolean;
+ resetApproval?: boolean;
+ refuel?: boolean;
+};
+
+type Protocol = {
+ name: string;
+ displayName?: string;
+ icon?: string;
+};
+
+enum ActionTypes {
+ BRIDGE = 'bridge',
+ SWAP = 'swap',
+ REFUEL = 'refuel',
+}
+
+type Step = {
+ action: ActionTypes;
+ srcChainId: ChainId;
+ destChainId?: ChainId;
+ srcAsset: BridgeAsset;
+ destAsset: BridgeAsset;
+ srcAmount: string;
+ destAmount: string;
+ protocol: Protocol;
+};
+
+type RefuelData = Step;
+
+export type Quote = {
+ requestId: string;
+ srcChainId: ChainId;
+ srcAsset: BridgeAsset;
+ srcTokenAmount: string;
+ destChainId: ChainId;
+ destAsset: BridgeAsset;
+ destTokenAmount: string;
+ feeData: Record
&
+ Partial>;
+ bridgeId: string;
+ bridges: string[];
+ steps: Step[];
+ refuel?: RefuelData;
+};
+
+export type QuoteResponse = {
+ quote: Quote;
+ approval: TxData | null;
+ trade: TxData;
+ estimatedProcessingTimeInSeconds: number;
+};
+
+enum ChainId {
+ ETH = 1,
+ OPTIMISM = 10,
+ BSC = 56,
+ POLYGON = 137,
+ ZKSYNC = 324,
+ BASE = 8453,
+ ARBITRUM = 42161,
+ AVALANCHE = 43114,
+ LINEA = 59144,
+}
+
+export enum FeeType {
+ METABRIDGE = 'metabridge',
+ REFUEL = 'refuel',
+}
+export type FeeData = {
+ amount: string;
+ asset: BridgeAsset;
+};
+export type TxData = {
+ chainId: ChainId;
+ to: string;
+ from: string;
+ value: string;
+ data: string;
+ gasLimit: number | null;
+};
diff --git a/ui/pages/bridge/utils/quote.ts b/ui/pages/bridge/utils/quote.ts
new file mode 100644
index 000000000000..0b83205580b4
--- /dev/null
+++ b/ui/pages/bridge/utils/quote.ts
@@ -0,0 +1,33 @@
+import { QuoteRequest } from '../types';
+
+export const isValidQuoteRequest = (
+ partialRequest: Partial,
+ requireAmount = true,
+): partialRequest is QuoteRequest => {
+ const STRING_FIELDS = ['srcTokenAddress', 'destTokenAddress'];
+ if (requireAmount) {
+ STRING_FIELDS.push('srcTokenAmount');
+ }
+ const NUMBER_FIELDS = ['srcChainId', 'destChainId', 'slippage'];
+
+ return (
+ STRING_FIELDS.every(
+ (field) =>
+ field in partialRequest &&
+ typeof partialRequest[field as keyof typeof partialRequest] ===
+ 'string' &&
+ partialRequest[field as keyof typeof partialRequest] !== undefined &&
+ partialRequest[field as keyof typeof partialRequest] !== '' &&
+ partialRequest[field as keyof typeof partialRequest] !== null,
+ ) &&
+ NUMBER_FIELDS.every(
+ (field) =>
+ field in partialRequest &&
+ typeof partialRequest[field as keyof typeof partialRequest] ===
+ 'number' &&
+ partialRequest[field as keyof typeof partialRequest] !== undefined &&
+ !isNaN(Number(partialRequest[field as keyof typeof partialRequest])) &&
+ partialRequest[field as keyof typeof partialRequest] !== null,
+ )
+ );
+};
diff --git a/ui/pages/bridge/utils/validators.ts b/ui/pages/bridge/utils/validators.ts
new file mode 100644
index 000000000000..01c716522968
--- /dev/null
+++ b/ui/pages/bridge/utils/validators.ts
@@ -0,0 +1,105 @@
+import { isStrictHexString } from '@metamask/utils';
+import { isValidHexAddress as isValidHexAddress_ } from '@metamask/controller-utils';
+import {
+ truthyDigitString,
+ validateData,
+} from '../../../../shared/lib/swaps-utils';
+import { BridgeFlag, FeatureFlagResponse } from '../types';
+
+type Validator = {
+ property: keyof ExpectedResponse | string;
+ type: string;
+ validator?: (value: unknown) => boolean;
+};
+
+export const validateResponse = (
+ validators: Validator[],
+ data: unknown,
+ urlUsed: string,
+): data is ExpectedResponse => {
+ return validateData(validators, data, urlUsed);
+};
+
+export const isValidNumber = (v: unknown): v is number => typeof v === 'number';
+const isValidObject = (v: unknown): v is object =>
+ typeof v === 'object' && v !== null;
+const isValidString = (v: unknown): v is string =>
+ typeof v === 'string' && v.length > 0;
+const isValidHexAddress = (v: unknown) =>
+ isValidString(v) && isValidHexAddress_(v, { allowNonPrefixed: false });
+
+export const FEATURE_FLAG_VALIDATORS = [
+ {
+ property: BridgeFlag.EXTENSION_CONFIG,
+ type: 'object',
+ validator: (
+ v: unknown,
+ ): v is Pick =>
+ isValidObject(v) &&
+ 'refreshRate' in v &&
+ isValidNumber(v.refreshRate) &&
+ 'maxRefreshCount' in v &&
+ isValidNumber(v.maxRefreshCount),
+ },
+ { property: BridgeFlag.EXTENSION_SUPPORT, type: 'boolean' },
+ {
+ property: BridgeFlag.NETWORK_SRC_ALLOWLIST,
+ type: 'object',
+ validator: (v: unknown): v is number[] =>
+ isValidObject(v) && Object.values(v).every(isValidNumber),
+ },
+ {
+ property: BridgeFlag.NETWORK_DEST_ALLOWLIST,
+ type: 'object',
+ validator: (v: unknown): v is number[] =>
+ isValidObject(v) && Object.values(v).every(isValidNumber),
+ },
+];
+
+export const TOKEN_VALIDATORS = [
+ { property: 'decimals', type: 'number' },
+ { property: 'address', type: 'string', validator: isValidHexAddress },
+ {
+ property: 'symbol',
+ type: 'string',
+ validator: (v: unknown) => isValidString(v) && v.length <= 12,
+ },
+];
+
+export const QUOTE_RESPONSE_VALIDATORS = [
+ { property: 'quote', type: 'object', validator: isValidObject },
+ { property: 'estimatedProcessingTimeInSeconds', type: 'number' },
+ {
+ property: 'approval',
+ type: 'object|undefined',
+ validator: (v: unknown) => v === undefined || isValidObject(v),
+ },
+ { property: 'trade', type: 'object', validator: isValidObject },
+];
+
+export const QUOTE_VALIDATORS = [
+ { property: 'requestId', type: 'string' },
+ { property: 'srcTokenAmount', type: 'string' },
+ { property: 'destTokenAmount', type: 'string' },
+ { property: 'bridgeId', type: 'string' },
+ { property: 'bridges', type: 'object', validator: isValidObject },
+ { property: 'srcChainId', type: 'number' },
+ { property: 'destChainId', type: 'number' },
+ { property: 'srcAsset', type: 'object', validator: isValidObject },
+ { property: 'destAsset', type: 'object', validator: isValidObject },
+ { property: 'feeData', type: 'object', validator: isValidObject },
+];
+
+export const FEE_DATA_VALIDATORS = [
+ { property: 'amount', type: 'string', validator: truthyDigitString },
+ { property: 'asset', type: 'object', validator: isValidObject },
+];
+
+export const TX_DATA_VALIDATORS = [
+ { property: 'chainId', type: 'number' },
+ { property: 'value', type: 'string', validator: isStrictHexString },
+ { property: 'gasLimit', type: 'number' },
+ { property: 'to', type: 'string', validator: isValidHexAddress },
+ { property: 'from', type: 'string', validator: isValidHexAddress },
+ { property: 'data', type: 'string', validator: isStrictHexString },
+];
diff --git a/ui/pages/confirm-decrypt-message/__snapshots__/confirm-decrypt-message.component.test.js.snap b/ui/pages/confirm-decrypt-message/__snapshots__/confirm-decrypt-message.component.test.js.snap
index cb4715881e27..d5e060e31d72 100644
--- a/ui/pages/confirm-decrypt-message/__snapshots__/confirm-decrypt-message.component.test.js.snap
+++ b/ui/pages/confirm-decrypt-message/__snapshots__/confirm-decrypt-message.component.test.js.snap
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`ConfirmDecryptMessage Component should match snapshot when preference is ETH currency 1`] = `
+exports[`ConfirmDecryptMessage Component matches snapshot 1`] = `
-
-
-
-
-
-
-
@@ -189,7 +112,7 @@ exports[`ConfirmDecryptMessage Component should match snapshot when preference i
- 966.987986 ABC
+ 966.987986 ETH
@@ -210,35 +133,40 @@ exports[`ConfirmDecryptMessage Component should match snapshot when preference i
- {"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}
-
-
-
-
+ {"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"}}
+
+
+
+
-
- Decrypt message
+
+
+ Decrypt message
+
+
-
+
+
+
+ Balance:
+
+
+ 966.987986 ETH
+
+
+
+
+
+
+ T
+
+
+ test would like to read this message to complete your action
+
+
+
+
+
+
+
+ This message cannot be decrypted due to error: Decrypt inline error
+
+
+
+
+
+
+ Decrypt message
+
+
+
+
+
+
+
+
+
+
+`;
+
+exports[`ConfirmDecryptMessage Component shows the correct message data 1`] = `
+
+
+
+
+
+
-
-
-
-
-
+ Test Account
+
@@ -451,7 +494,7 @@ exports[`ConfirmDecryptMessage Component should match snapshot when preference i
- 1520956.064158 DEF
+ 966.987986 ETH
@@ -472,35 +515,66 @@ exports[`ConfirmDecryptMessage Component should match snapshot when preference i
- {"domain":{"chainId":97,"name":"Ether Mail","verifyingContract":"0xCcCCccccCCCCcCCCCCCcCcCccCcCCCcCcccccccC","version":"1"},"message":{"contents":"Hello, Bob!","from":{"name":"Cow","wallets":["0xCD2a3d9F938E13CD947Ec05AbC7FE734Df8DD826","0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF"]},"to":[{"name":"Bob","wallets":["0xbBbBBBBbbBBBbbbBbbBbbbbBBbBbbbbBbBbbBBbB","0xB0BdaBea57B0BDABeA57b0bdABEA57b0BDabEa57","0xB0B0b0b0b0b0B000000000000000000000000000"]}]},"primaryType":"Mail","types":{"EIP712Domain":[{"name":"name","type":"string"},{"name":"version","type":"string"},{"name":"chainId","type":"uint256"},{"name":"verifyingContract","type":"address"}],"Mail":[{"name":"from","type":"Person"},{"name":"to","type":"Person[]"},{"name":"contents","type":"string"}],"Person":[{"name":"name","type":"string"},{"name":"wallets","type":"address[]"}]}}
-
+
+ raw message
+
+
+
+
+
+
+
+ Decrypt message
+
+
+
-
-
- Decrypt message
+
+ Copy encrypted message
+
+
-