diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b179af364c9a..b5aba4fdb94e 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -136,6 +136,9 @@ "accountActivityText": { "message": "Select the accounts you want to be notified about:" }, + "accountBalance": { + "message": "Account balance" + }, "accountDetails": { "message": "Account details" }, @@ -1066,6 +1069,9 @@ "confirmTitleDescDeployContract": { "message": "This site wants you to deploy a contract" }, + "confirmTitleDescERC20ApproveTransaction": { + "message": "This site wants permission to withdraw your tokens" + }, "confirmTitleDescPermitSignature": { "message": "This site wants permission to spend your tokens." }, @@ -5190,6 +5196,9 @@ "spendingCapRequest": { "message": "Spending cap request for your $1" }, + "spendingCapTooltipDesc": { + "message": "This is the amount of tokens the spender will be able to access on your behalf." + }, "srpInputNumberOfWords": { "message": "I have a $1-word phrase", "description": "This is the text for each option in the dropdown where a user selects how many words their secret recovery phrase has during import. The $1 is the number of words (either 12, 15, 18, 21, or 24)." diff --git a/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts new file mode 100644 index 000000000000..52cb4f976641 --- /dev/null +++ b/test/e2e/tests/confirmations/transactions/erc20-approve-redesign.spec.ts @@ -0,0 +1,227 @@ +/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +import { MockttpServer } from 'mockttp'; +import { tinyDelayMs, veryLargeDelayMs, WINDOW_TITLES } from '../../../helpers'; +import { Driver } from '../../../webdriver/driver'; +import { scrollAndConfirmAndAssertConfirm } from '../helpers'; +import { + openDAppWithContract, + TestSuiteArguments, + toggleAdvancedDetails, +} from './shared'; + +const { + defaultGanacheOptions, + defaultGanacheOptionsForType2Transactions, + withFixtures, +} = require('../../../helpers'); +const FixtureBuilder = require('../../../fixture-builder'); +const { SMART_CONTRACTS } = require('../../../seeder/smart-contracts'); + +describe('Confirmation Redesign ERC20 Approve Component', function () { + const smartContract = SMART_CONTRACTS.HST; + + describe('Submit an Approve transaction @no-mmi', function () { + it('Sends a type 0 transaction (Legacy)', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptions, + smartContract, + testSpecificMock: mocks, + title: this.test?.fullTitle(), + }, + async ({ driver, contractRegistry }: TestSuiteArguments) => { + await openDAppWithContract(driver, contractRegistry, smartContract); + + await importTST(driver); + + await createERC20ApproveTransaction(driver); + + await assertApproveDetails(driver); + + await confirmApproveTransaction(driver); + }, + ); + }); + + it('Sends a type 2 transaction (EIP1559)', async function () { + await withFixtures( + { + dapp: true, + fixtures: new FixtureBuilder() + .withPermissionControllerConnectedToTestDapp() + .withPreferencesController({ + preferences: { + redesignedConfirmationsEnabled: true, + isRedesignedConfirmationsDeveloperEnabled: true, + }, + }) + .build(), + ganacheOptions: defaultGanacheOptionsForType2Transactions, + smartContract, + testSpecificMock: mocks, + title: this.test?.fullTitle(), + }, + async ({ driver, contractRegistry }: TestSuiteArguments) => { + await openDAppWithContract(driver, contractRegistry, smartContract); + + await importTST(driver); + + await createERC20ApproveTransaction(driver); + + await assertApproveDetails(driver); + + await confirmApproveTransaction(driver); + }, + ); + }); + }); +}); + +async function mocked4Bytes(mockServer: MockttpServer) { + return await mockServer + .forGet('https://www.4byte.directory/api/v1/signatures/') + .withQuery({ hex_signature: '0x095ea7b3' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 149, + created_at: '2016-07-09T03:58:29.617584Z', + text_signature: 'approve(address,uint256)', + hex_signature: '0x095ea7b3', + bytes_signature: '\t^§³', + }, + ], + }, + })); +} + +async function mocks(server: MockttpServer) { + return [await mocked4Bytes(server)]; +} + +async function importTST(driver: Driver) { + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + await driver.clickElement('[data-testid="import-token-button"]'); + + await driver.waitForSelector({ + css: '.import-tokens-modal__button-tab', + text: 'Custom token', + }); + await driver.clickElement({ + css: '.import-tokens-modal__button-tab', + text: 'Custom token', + }); + + await driver.fill( + '[data-testid="import-tokens-modal-custom-address"]', + '0x581c3C1A2A4EBDE2A0Df29B5cf4c116E42945947', + ); + + await driver.delay(tinyDelayMs); + + await driver.clickElement({ + css: '[data-testid="import-tokens-button-next"]', + text: 'Next', + }); + + await driver.clickElement({ + css: '[data-testid="import-tokens-modal-import-button"]', + text: 'Import', + }); +} + +async function createERC20ApproveTransaction(driver: Driver) { + await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); + await driver.clickElement('#approveTokens'); +} + +async function assertApproveDetails(driver: Driver) { + await driver.delay(veryLargeDelayMs); + await driver.waitUntilXWindowHandles(3); + await driver.switchToWindowWithTitle(WINDOW_TITLES.Dialog); + + await driver.waitForSelector({ + css: 'h2', + text: 'Spending cap request', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'This site wants permission to withdraw your tokens', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Estimated changes', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Spending cap', + }); + + await driver.waitForSelector({ + css: 'p', + text: '7', + }); + + await toggleAdvancedDetails(driver); + + await driver.waitForSelector({ + css: 'p', + text: 'Spender', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Request from', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Interacting with', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Method', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Account balance', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Spending cap', + }); +} + +async function confirmApproveTransaction(driver: Driver) { + await scrollAndConfirmAndAssertConfirm(driver); + + await driver.delay(veryLargeDelayMs); + await driver.waitUntilXWindowHandles(2); + await driver.switchToWindowWithTitle(WINDOW_TITLES.ExtensionInFullScreenView); + + await driver.clickElement({ text: 'Activity', tag: 'button' }); + await driver.waitForSelector( + '.transaction-list__completed-transactions .activity-list-item:nth-of-type(1)', + ); +} diff --git a/test/e2e/tests/confirmations/transactions/erc721-approve-redesign.spec.ts b/test/e2e/tests/confirmations/transactions/erc721-approve-redesign.spec.ts index b1ed24a9c171..f91b1e8ba1d2 100644 --- a/test/e2e/tests/confirmations/transactions/erc721-approve-redesign.spec.ts +++ b/test/e2e/tests/confirmations/transactions/erc721-approve-redesign.spec.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires */ +import { MockttpServer } from 'mockttp'; import { veryLargeDelayMs, WINDOW_TITLES } from '../../../helpers'; import { Driver } from '../../../webdriver/driver'; import { scrollAndConfirmAndAssertConfirm } from '../helpers'; @@ -35,6 +36,7 @@ describe('Confirmation Redesign ERC721 Approve Component', function () { .build(), ganacheOptions: defaultGanacheOptions, smartContract, + testSpecificMock: mocks, title: this.test?.fullTitle(), }, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -66,6 +68,7 @@ describe('Confirmation Redesign ERC721 Approve Component', function () { .build(), ganacheOptions: defaultGanacheOptionsForType2Transactions, smartContract, + testSpecificMock: mocks, title: this.test?.fullTitle(), }, async ({ driver, contractRegistry }: TestSuiteArguments) => { @@ -83,6 +86,33 @@ describe('Confirmation Redesign ERC721 Approve Component', function () { }); }); +async function mocked4Bytes(mockServer: MockttpServer) { + return await mockServer + .forGet('https://www.4byte.directory/api/v1/signatures/') + .withQuery({ hex_signature: '0x095ea7b3' }) + .thenCallback(() => ({ + statusCode: 200, + json: { + count: 1, + next: null, + previous: null, + results: [ + { + id: 149, + created_at: '2016-07-09T03:58:29.617584Z', + text_signature: 'approve(address,uint256)', + hex_signature: '0x095ea7b3', + bytes_signature: '\t^§³', + }, + ], + }, + })); +} + +async function mocks(server: MockttpServer) { + return [await mocked4Bytes(server)]; +} + async function createMintTransaction(driver: Driver) { await driver.switchToWindowWithTitle(WINDOW_TITLES.TestDApp); await driver.clickElement('#mintButton'); @@ -121,8 +151,28 @@ async function assertApproveDetails(driver: Driver) { text: 'This site wants permission to withdraw your NFTs', }); + await driver.waitForSelector({ + css: 'p', + text: 'Estimated changes', + }); + + await driver.waitForSelector({ + css: 'p', + text: 'Withdraw', + }); + + await driver.waitForSelector({ + css: 'p', + text: '#1', + }); + await toggleAdvancedDetails(driver); + await driver.waitForSelector({ + css: 'p', + text: 'Spender', + }); + await driver.waitForSelector({ css: 'p', text: 'Request from', diff --git a/test/integration/confirmations/transactions/erc20-approve.test.tsx b/test/integration/confirmations/transactions/erc20-approve.test.tsx new file mode 100644 index 000000000000..824ca80e0b84 --- /dev/null +++ b/test/integration/confirmations/transactions/erc20-approve.test.tsx @@ -0,0 +1,154 @@ +import { ApprovalType } from '@metamask/controller-utils'; +import { waitFor } from '@testing-library/react'; +import nock from 'nock'; +import { useIsNFT } from '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft'; +import * as backgroundConnection from '../../../../ui/store/background-connection'; +import { integrationTestRender } from '../../../lib/render-helpers'; +import mockMetaMaskState from '../../data/integration-init-state.json'; +import { createMockImplementation, mock4byte } from '../../helpers'; +import { getUnapprovedApproveTransaction } from './transactionDataHelpers'; + +jest.mock('../../../../ui/store/background-connection', () => ({ + ...jest.requireActual('../../../../ui/store/background-connection'), + submitRequestToBackground: jest.fn(), +})); + +jest.mock( + '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft', + () => ({ + ...jest.requireActual( + '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft', + ), + useIsNFT: jest.fn(), + }), +); + +const mockedBackgroundConnection = jest.mocked(backgroundConnection); + +const backgroundConnectionMocked = { + onNotification: jest.fn(), +}; +export const pendingTransactionId = '48a75190-45ca-11ef-9001-f3886ec2397c'; +export const pendingTransactionTime = new Date().getTime(); + +const getMetaMaskStateWithUnapprovedApproveTransaction = ( + accountAddress: string, +) => { + return { + ...mockMetaMaskState, + preferences: { + ...mockMetaMaskState.preferences, + redesignedConfirmationsEnabled: true, + }, + pendingApprovals: { + [pendingTransactionId]: { + id: pendingTransactionId, + origin: 'origin', + time: pendingTransactionTime, + type: ApprovalType.Transaction, + requestData: { + txId: pendingTransactionId, + }, + requestState: null, + expectsResult: false, + }, + }, + pendingApprovalCount: 1, + knownMethodData: { + '0x3b4b1381': { + name: 'Mint NFTs', + params: [ + { + type: 'uint256', + }, + ], + }, + }, + transactions: [ + getUnapprovedApproveTransaction( + accountAddress, + pendingTransactionId, + pendingTransactionTime, + ), + ], + }; +}; + +const advancedDetailsMockedRequests = { + getGasFeeTimeEstimate: { + lowerTimeBound: new Date().getTime(), + upperTimeBound: new Date().getTime(), + }, + getNextNonce: '9', + decodeTransactionData: { + data: [ + { + name: 'approve', + params: [ + { + type: 'address', + value: '0x2e0D7E8c45221FcA00d74a3609A0f7097035d09B', + }, + { + type: 'uint256', + value: 1, + }, + ], + }, + ], + source: 'FourByte', + }, +}; + +const setupSubmitRequestToBackgroundMocks = ( + mockRequests?: Record, +) => { + mockedBackgroundConnection.submitRequestToBackground.mockImplementation( + createMockImplementation({ + ...advancedDetailsMockedRequests, + ...(mockRequests ?? {}), + }), + ); +}; + +describe('ERC721 Approve Confirmation', () => { + let useIsNFTMock; + beforeEach(() => { + jest.resetAllMocks(); + setupSubmitRequestToBackgroundMocks(); + const APPROVE_NFT_HEX_SIG = '0x095ea7b3'; + mock4byte(APPROVE_NFT_HEX_SIG); + useIsNFTMock = jest + .fn() + .mockImplementation(() => ({ isNFT: false, decimals: '18' })); + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('displays approve details with correct data', async () => { + const account = + mockMetaMaskState.internalAccounts.accounts[ + mockMetaMaskState.internalAccounts + .selectedAccount as keyof typeof mockMetaMaskState.internalAccounts.accounts + ]; + + const mockedMetaMaskState = + getMetaMaskStateWithUnapprovedApproveTransaction(account.address); + + const { getByText } = await integrationTestRender({ + preloadedState: mockedMetaMaskState, + backgroundConnection: backgroundConnectionMocked, + }); + + await waitFor(() => { + expect(getByText('Spending cap request')).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText('Request from')).toBeInTheDocument(); + }); + }); +}); diff --git a/test/integration/confirmations/transactions/erc721-approve.test.tsx b/test/integration/confirmations/transactions/erc721-approve.test.tsx index 9d9a270963b1..47f0e2121eb8 100644 --- a/test/integration/confirmations/transactions/erc721-approve.test.tsx +++ b/test/integration/confirmations/transactions/erc721-approve.test.tsx @@ -1,6 +1,7 @@ import { ApprovalType } from '@metamask/controller-utils'; import { waitFor } from '@testing-library/react'; import nock from 'nock'; +import { useIsNFT } from '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft'; import * as backgroundConnection from '../../../../ui/store/background-connection'; import { integrationTestRender } from '../../../lib/render-helpers'; import mockMetaMaskState from '../../data/integration-init-state.json'; @@ -12,6 +13,16 @@ jest.mock('../../../../ui/store/background-connection', () => ({ submitRequestToBackground: jest.fn(), })); +jest.mock( + '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft', + () => ({ + ...jest.requireActual( + '../../../../ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft', + ), + useIsNFT: jest.fn(), + }), +); + const mockedBackgroundConnection = jest.mocked(backgroundConnection); const backgroundConnectionMocked = { @@ -101,11 +112,14 @@ const setupSubmitRequestToBackgroundMocks = ( }; describe('ERC721 Approve Confirmation', () => { + let useIsNFTMock; beforeEach(() => { jest.resetAllMocks(); setupSubmitRequestToBackgroundMocks(); const APPROVE_NFT_HEX_SIG = '0x095ea7b3'; mock4byte(APPROVE_NFT_HEX_SIG); + useIsNFTMock = jest.fn().mockImplementation(() => ({ isNFT: true })); + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); }); afterEach(() => { diff --git a/ui/pages/confirmations/components/confirm/info/approve/approve.tsx b/ui/pages/confirmations/components/confirm/info/approve/approve.tsx index 0dfd100a719a..600a6c653f80 100644 --- a/ui/pages/confirmations/components/confirm/info/approve/approve.tsx +++ b/ui/pages/confirmations/components/confirm/info/approve/approve.tsx @@ -1,9 +1,20 @@ import { NameType } from '@metamask/name-controller'; -import { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; import React from 'react'; import { useSelector } from 'react-redux'; +import { + ConfirmInfoRow, + ConfirmInfoRowDivider, + ConfirmInfoRowText, +} from '../../../../../../components/app/confirm/info/row'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; import Name from '../../../../../../components/app/name'; import { Box, Text } from '../../../../../../components/component-library'; +import Tooltip from '../../../../../../components/ui/tooltip'; import { AlignItems, BackgroundColor, @@ -14,13 +25,18 @@ import { } from '../../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../../hooks/useI18nContext'; import { currentConfirmationSelector } from '../../../../../../selectors'; +import { useAssetDetails } from '../../../../hooks/useAssetDetails'; import { selectConfirmationAdvancedDetailsOpen } from '../../../../selectors/preferences'; -import { useDecodedTransactionData } from '../hooks/useDecodedTransactionData'; import { AdvancedDetails } from '../shared/advanced-details/advanced-details'; import { GasFeesSection } from '../shared/gas-fees-section/gas-fees-section'; import StaticSimulation from '../shared/static-simulation/static-simulation'; import { Container } from '../shared/transaction-data/transaction-data'; import { ApproveDetails } from './approve-details/approve-details'; +import { + UNLIMITED_MSG, + useApproveTokenSimulation, +} from './hooks/use-approve-token-simulation'; +import { useIsNFT } from './hooks/use-is-nft'; const ApproveStaticSimulation = () => { const t = useI18nContext(); @@ -29,9 +45,16 @@ const ApproveStaticSimulation = () => { currentConfirmationSelector, ) as TransactionMeta; - const decodedResponse = useDecodedTransactionData(); + const { decimals } = useAssetDetails( + transactionMeta.txParams.to, + transactionMeta.txParams.from, + transactionMeta.txParams.data, + ); - const { value, pending } = decodedResponse; + const { tokenAmount, formattedTokenNum, value, pending } = + useApproveTokenSimulation(transactionMeta, decimals || '0'); + + const { isNFT } = useIsNFT(transactionMeta); if (pending) { return ; @@ -41,7 +64,18 @@ const ApproveStaticSimulation = () => { return null; } - const tokenId = `#${value.data[0].params[1].value}`; + const formattedTokenText = ( + + {tokenAmount === UNLIMITED_MSG ? t('unlimited') : tokenAmount} + + ); const simulationElements = ( <> @@ -51,16 +85,11 @@ const ApproveStaticSimulation = () => { marginInlineEnd={1} minWidth={BlockSize.Zero} > - - {tokenId} - + {tokenAmount === UNLIMITED_MSG ? ( + {formattedTokenText} + ) : ( + formattedTokenText + )} { title={t('simulationDetailsTitle')} titleTooltip={t('simulationDetailsTitleTooltip')} description={t('simulationDetailsApproveDesc')} - simulationHeading={t('simulationApproveHeading')} + simulationHeading={ + isNFT ? t('simulationApproveHeading') : t('spendingCap') + } simulationElements={simulationElements} /> ); }; +const SpendingCapGroup = ({ + tokenSymbol, + decimals, +}: { + tokenSymbol: string; + decimals: string; +}) => { + const t = useI18nContext(); + + const transactionMeta = useSelector( + currentConfirmationSelector, + ) as TransactionMeta; + + const { tokenAmount, formattedTokenNum, value } = useApproveTokenSimulation( + transactionMeta, + decimals, + ); + + const SpendingCapElement = ( + console.log('TODO on a following ticket') + : undefined + } + editIconClassName="edit-spending-cap" + /> + ); + + if (!value) { + return null; + } + + return ( + <> + + + + {tokenAmount === UNLIMITED_MSG ? ( + {SpendingCapElement} + ) : ( + SpendingCapElement + )} + + + ); +}; + +const SpendingCap = () => { + const t = useI18nContext(); + + const transactionMeta = useSelector( + currentConfirmationSelector, + ) as TransactionMeta; + + const { userBalance, tokenSymbol, decimals } = useAssetDetails( + transactionMeta.txParams.to, + transactionMeta.txParams.from, + transactionMeta.txParams.data, + ); + + const accountBalance = new BigNumber(userBalance || '0') + .dividedBy(new BigNumber(10).pow(Number(decimals || '0'))) + .toNumber(); + + const { pending } = useApproveTokenSimulation( + transactionMeta, + decimals || '0', + ); + + if (pending) { + return ; + } + + return ( + + + + + + + + ); +}; + const ApproveInfo = () => { const transactionMeta = useSelector( currentConfirmationSelector, @@ -90,6 +217,8 @@ const ApproveInfo = () => { selectConfirmationAdvancedDetailsOpen, ); + const { isNFT } = useIsNFT(transactionMeta); + if (!transactionMeta?.txParams) { return null; } @@ -98,6 +227,7 @@ const ApproveInfo = () => { <> + {!isNFT && } {showAdvancedDetails && } diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts new file mode 100644 index 000000000000..1dc5a6bc8cd2 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.test.ts @@ -0,0 +1,158 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { + CONTRACT_INTERACTION_SENDER_ADDRESS, + genUnapprovedContractInteractionConfirmation, +} from '../../../../../../../../test/data/confirmations/contract-interaction'; +import mockState from '../../../../../../../../test/data/mock-state.json'; +import { renderHookWithProvider } from '../../../../../../../../test/lib/render-helpers'; +import { useDecodedTransactionData } from '../../hooks/useDecodedTransactionData'; +import { useApproveTokenSimulation } from './use-approve-token-simulation'; +import { useIsNFT } from './use-is-nft'; + +jest.mock('./use-is-nft', () => ({ + ...jest.requireActual('./use-is-nft'), + useIsNFT: jest.fn(), +})); + +jest.mock('../../hooks/useDecodedTransactionData', () => ({ + ...jest.requireActual('../../hooks/useDecodedTransactionData'), + useDecodedTransactionData: jest.fn(), +})); + +describe('useApproveTokenSimulation', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('returns the token id for NFT', async () => { + const useIsNFTMock = jest.fn().mockImplementation(() => ({ isNFT: true })); + + const useDecodedTransactionDataMock = jest.fn().mockImplementation(() => ({ + pending: false, + value: { + data: [ + { + name: 'approve', + params: [ + { + type: 'address', + value: '0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4', + }, + { + type: 'uint256', + value: 70000, + }, + ], + }, + ], + source: 'FourByte', + }, + })); + + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); + (useDecodedTransactionData as jest.Mock).mockImplementation( + useDecodedTransactionDataMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result } = renderHookWithProvider( + () => useApproveTokenSimulation(transactionMeta, '4'), + mockState, + ); + + expect(result.current).toMatchInlineSnapshot(` + { + "formattedTokenNum": 7, + "pending": undefined, + "tokenAmount": "#7", + "value": { + "data": [ + { + "name": "approve", + "params": [ + { + "type": "address", + "value": "0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4", + }, + { + "type": "uint256", + "value": 70000, + }, + ], + }, + ], + "source": "FourByte", + }, + } + `); + }); + + it('returns "UNLIMITED MESSAGE" token amount for fungible tokens approvals equal or over the total number of tokens in circulation', async () => { + const useIsNFTMock = jest.fn().mockImplementation(() => ({ isNFT: false })); + + const useDecodedTransactionDataMock = jest.fn().mockImplementation(() => ({ + pending: false, + value: { + data: [ + { + name: 'approve', + params: [ + { + type: 'address', + value: '0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4', + }, + { + type: 'uint256', + value: 10 ** 15, + }, + ], + }, + ], + source: 'FourByte', + }, + })); + + (useIsNFT as jest.Mock).mockImplementation(useIsNFTMock); + (useDecodedTransactionData as jest.Mock).mockImplementation( + useDecodedTransactionDataMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result } = renderHookWithProvider( + () => useApproveTokenSimulation(transactionMeta, '0'), + mockState, + ); + + expect(result.current).toMatchInlineSnapshot(` + { + "formattedTokenNum": "1,000,000,000,000,000", + "pending": undefined, + "tokenAmount": "UNLIMITED MESSAGE", + "value": { + "data": [ + { + "name": "approve", + "params": [ + { + "type": "address", + "value": "0x9bc5baF874d2DA8D216aE9f137804184EE5AfEF4", + }, + { + "type": "uint256", + "value": 1000000000000000, + }, + ], + }, + ], + "source": "FourByte", + }, + } + `); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts new file mode 100644 index 000000000000..20cdb42a3947 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-approve-token-simulation.ts @@ -0,0 +1,52 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { BigNumber } from 'bignumber.js'; +import { useSelector } from 'react-redux'; +import { getIntlLocale } from '../../../../../../../ducks/locale/locale'; +import { useDecodedTransactionData } from '../../hooks/useDecodedTransactionData'; +import { useIsNFT } from './use-is-nft'; + +export const UNLIMITED_MSG = 'UNLIMITED MESSAGE'; + +const UNLIMITED_THRESHOLD = 10 ** 15; + +function isSpendingCapUnlimited(decodedSpendingCap: number) { + return decodedSpendingCap >= UNLIMITED_THRESHOLD; +} + +export const useApproveTokenSimulation = ( + transactionMeta: TransactionMeta, + decimals: string, +) => { + const locale = useSelector(getIntlLocale); + + const { isNFT, pending: isNFTPending } = useIsNFT(transactionMeta); + + const decodedResponse = useDecodedTransactionData(); + + const { value, pending } = decodedResponse; + + const decodedSpendingCap = value + ? new BigNumber(value.data[0].params[1].value) + .dividedBy(new BigNumber(10).pow(Number(decimals))) + .toNumber() + : 0; + + const tokenPrefix = isNFT ? '#' : ''; + const formattedTokenNum = isNFT + ? decodedSpendingCap + : new Intl.NumberFormat(locale).format(decodedSpendingCap); + + let tokenAmount; + if (!isNFT && isSpendingCapUnlimited(decodedSpendingCap)) { + tokenAmount = UNLIMITED_MSG; + } else { + tokenAmount = `${tokenPrefix}${formattedTokenNum}`; + } + + return { + tokenAmount, + formattedTokenNum, + value, + pending: pending || isNFTPending, + }; +}; diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.test.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.test.ts new file mode 100644 index 000000000000..85f2c0eed6e1 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.test.ts @@ -0,0 +1,63 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { TokenStandard } from '../../../../../../../../shared/constants/transaction'; +import { + CONTRACT_INTERACTION_SENDER_ADDRESS, + genUnapprovedContractInteractionConfirmation, +} from '../../../../../../../../test/data/confirmations/contract-interaction'; +import mockState from '../../../../../../../../test/data/mock-state.json'; +import { renderHookWithProvider } from '../../../../../../../../test/lib/render-helpers'; +import { getTokenStandardAndDetails } from '../../../../../../../store/actions'; +import { useIsNFT } from './use-is-nft'; + +jest.mock('../../../../../../../store/actions', () => ({ + ...jest.requireActual('../../../../../../../store/actions'), + getTokenStandardAndDetails: jest.fn(), +})); + +describe('useIsNFT', () => { + it('identifies NFT in token with 0 decimals', async () => { + const getTokenStandardAndDetailsMock = jest + .fn() + .mockImplementation(() => ({ standard: TokenStandard.ERC721 })); + + (getTokenStandardAndDetails as jest.Mock).mockImplementation( + getTokenStandardAndDetailsMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result, waitForNextUpdate } = renderHookWithProvider( + () => useIsNFT(transactionMeta), + mockState, + ); + + await waitForNextUpdate(); + + expect(result.current.isNFT).toMatchInlineSnapshot(`true`); + }); + + it('identifies fungible in token with greater than 0 decimals', async () => { + const getTokenStandardAndDetailsMock = jest + .fn() + .mockImplementation(() => ({ standard: TokenStandard.ERC20 })); + + (getTokenStandardAndDetails as jest.Mock).mockImplementation( + getTokenStandardAndDetailsMock, + ); + + const transactionMeta = genUnapprovedContractInteractionConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result, waitForNextUpdate } = renderHookWithProvider( + () => useIsNFT(transactionMeta), + mockState, + ); + + await waitForNextUpdate(); + + expect(result.current.isNFT).toMatchInlineSnapshot(`false`); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.ts new file mode 100644 index 000000000000..9f60ef255acb --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-is-nft.ts @@ -0,0 +1,18 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { TokenStandard } from '../../../../../../../../shared/constants/transaction'; +import { useAsyncResult } from '../../../../../../../hooks/useAsyncResult'; +import { getTokenStandardAndDetails } from '../../../../../../../store/actions'; + +export const useIsNFT = ( + transactionMeta: TransactionMeta, +): { isNFT: boolean; pending: boolean } => { + const { value, pending } = useAsyncResult(async () => { + return await getTokenStandardAndDetails( + transactionMeta?.txParams?.to as string, + ); + }, [transactionMeta]); + + const isNFT = value?.standard !== TokenStandard.ERC20; + + return { pending, isNFT }; +}; diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.test.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.test.ts new file mode 100644 index 000000000000..aaf9e97285ef --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.test.ts @@ -0,0 +1,49 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { + CONTRACT_INTERACTION_SENDER_ADDRESS, + genUnapprovedApproveConfirmation, +} from '../../../../../../../../test/data/confirmations/contract-interaction'; +import mockState from '../../../../../../../../test/data/mock-state.json'; +import { renderHookWithProvider } from '../../../../../../../../test/lib/render-helpers'; +import { useAccountTotalFiatBalance } from '../../../../../../../hooks/useAccountTotalFiatBalance'; +import { useReceivedToken } from './use-received-token'; + +jest.mock('../../../../../../../hooks/useAccountTotalFiatBalance', () => ({ + ...jest.requireActual( + '../../../../../../../hooks/useAccountTotalFiatBalance', + ), + useAccountTotalFiatBalance: jest.fn(), +})); + +describe('useReceivedToken', () => { + it('returns receivedToken correctly', async () => { + const useAccountTotalFiatBalanceMock = jest.fn().mockImplementation(() => ({ + tokensWithBalances: [ + { + address: '0x076146c765189d51be3160a2140cf80bfc73ad68', + symbol: 'Nice', + }, + ], + })); + + (useAccountTotalFiatBalance as jest.Mock).mockImplementation( + useAccountTotalFiatBalanceMock, + ); + + const transactionMeta = genUnapprovedApproveConfirmation({ + address: CONTRACT_INTERACTION_SENDER_ADDRESS, + }) as TransactionMeta; + + const { result } = renderHookWithProvider(() => useReceivedToken(), { + ...mockState, + confirm: { currentConfirmation: transactionMeta }, + }); + + expect(result.current.receivedToken).toMatchInlineSnapshot(` + { + "address": "0x076146c765189d51be3160a2140cf80bfc73ad68", + "symbol": "Nice", + } + `); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.ts b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.ts new file mode 100644 index 000000000000..7fe3b04e01b9 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/approve/hooks/use-received-token.ts @@ -0,0 +1,40 @@ +import { TransactionMeta } from '@metamask/transaction-controller'; +import { useSelector } from 'react-redux'; +import { normalizeSafeAddress } from '../../../../../../../../app/scripts/lib/multichain/address'; +import { useAccountTotalFiatBalance } from '../../../../../../../hooks/useAccountTotalFiatBalance'; +import { + currentConfirmationSelector, + getSelectedAccount, +} from '../../../../../../../selectors'; + +export type TokenWithBalance = { + address: string; + balance: string; + balanceError: unknown; + decimals: number; + image: unknown; + isERC721: unknown; + string: string; + symbol: string; +}; + +export const useReceivedToken = () => { + const transactionMeta = useSelector( + currentConfirmationSelector, + ) as TransactionMeta; + + const selectedAccount = useSelector(getSelectedAccount); + + const { tokensWithBalances } = useAccountTotalFiatBalance( + selectedAccount, + true, + ) as { tokensWithBalances: TokenWithBalance[] }; + + const receivedToken = tokensWithBalances.find( + (token) => + normalizeSafeAddress(transactionMeta.txParams.to as string) === + normalizeSafeAddress(token.address), + ); + + return { receivedToken }; +}; diff --git a/ui/pages/confirmations/components/confirm/title/title.tsx b/ui/pages/confirmations/components/confirm/title/title.tsx index 5d1bf2cf8c4e..ad4fa8713e2e 100644 --- a/ui/pages/confirmations/components/confirm/title/title.tsx +++ b/ui/pages/confirmations/components/confirm/title/title.tsx @@ -1,22 +1,26 @@ -import { TransactionType } from '@metamask/transaction-controller'; +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import React, { memo, useMemo } from 'react'; import { useSelector } from 'react-redux'; +import GeneralAlert from '../../../../../components/app/alert-system/general-alert/general-alert'; +import { getHighestSeverity } from '../../../../../components/app/alert-system/utils'; import { Box, Text } from '../../../../../components/component-library'; import { TextAlign, TextColor, TextVariant, } from '../../../../../helpers/constants/design-system'; +import useAlerts from '../../../../../hooks/useAlerts'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { currentConfirmationSelector } from '../../../../../selectors'; -import useAlerts from '../../../../../hooks/useAlerts'; -import { getHighestSeverity } from '../../../../../components/app/alert-system/utils'; -import GeneralAlert from '../../../../../components/app/alert-system/general-alert/general-alert'; import { Confirmation, SignatureRequestType } from '../../../types/confirm'; import { isPermitSignatureRequest, isSIWESignatureRequest, } from '../../../utils'; +import { useIsNFT } from '../info/approve/hooks/use-is-nft'; function ConfirmBannerAlert({ ownerId }: { ownerId: string }) { const t = useI18nContext(); @@ -56,12 +60,19 @@ function ConfirmBannerAlert({ ownerId }: { ownerId: string }) { type IntlFunction = (str: string) => string; -const getTitle = (t: IntlFunction, confirmation?: Confirmation) => { +const getTitle = ( + t: IntlFunction, + confirmation?: Confirmation, + isNFT?: boolean, +) => { switch (confirmation?.type) { case TransactionType.contractInteraction: return t('confirmTitleTransaction'); case TransactionType.tokenMethodApprove: - return t('confirmTitleApproveTransaction'); + if (isNFT) { + return t('confirmTitleApproveTransaction'); + } + return t('confirmTitlePermitSignature'); case TransactionType.deployContract: return t('confirmTitleDeployContract'); case TransactionType.personalSign: @@ -78,12 +89,19 @@ const getTitle = (t: IntlFunction, confirmation?: Confirmation) => { } }; -const getDescription = (t: IntlFunction, confirmation?: Confirmation) => { +const getDescription = ( + t: IntlFunction, + confirmation?: Confirmation, + isNFT?: boolean, +) => { switch (confirmation?.type) { case TransactionType.contractInteraction: return ''; case TransactionType.tokenMethodApprove: - return t('confirmTitleDescApproveTransaction'); + if (isNFT) { + return t('confirmTitleDescApproveTransaction'); + } + return t('confirmTitleDescERC20ApproveTransaction'); case TransactionType.deployContract: return t('confirmTitleDescDeployContract'); case TransactionType.personalSign: @@ -104,14 +122,16 @@ const ConfirmTitle: React.FC = memo(() => { const t = useI18nContext(); const currentConfirmation = useSelector(currentConfirmationSelector); + const { isNFT } = useIsNFT(currentConfirmation as TransactionMeta); + const title = useMemo( - () => getTitle(t as IntlFunction, currentConfirmation), - [currentConfirmation], + () => getTitle(t as IntlFunction, currentConfirmation, isNFT), + [currentConfirmation, isNFT], ); const description = useMemo( - () => getDescription(t as IntlFunction, currentConfirmation), - [currentConfirmation], + () => getDescription(t as IntlFunction, currentConfirmation, isNFT), + [currentConfirmation, isNFT], ); if (!currentConfirmation) {