diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index 5fd9282994d3..f5308396f291 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -595,6 +595,9 @@ "message": "$1 and $2", "description": "$1 is the first item, $2 is the second item. Used in Snap Install Warning modal." }, + "annual": { + "message": "Annual" + }, "appDescription": { "message": "The world's most trusted crypto wallet", "description": "The description of the application" @@ -2429,6 +2432,9 @@ "message": "Error with $1", "description": "$1 represents the name of the snap" }, + "estimatedChanges": { + "message": "Estimated changes" + }, "estimatedFee": { "message": "Estimated fee" }, @@ -2584,6 +2590,9 @@ "form": { "message": "form" }, + "freeSevenDayTrial": { + "message": "Free 7-day trial" + }, "from": { "message": "From" }, @@ -3507,6 +3516,12 @@ "missingSettingRequest": { "message": "Request here" }, + "month": { + "message": "month" + }, + "monthly": { + "message": "Monthly" + }, "more": { "message": "more" }, @@ -5588,6 +5603,9 @@ "settingsSubHeadingSignaturesAndTransactions": { "message": "Signature and transaction requests" }, + "shieldConfirmMembership": { + "message": "Confirm membership" + }, "shieldEntryModalAssetCoverage": { "message": "$10,000 asset coverage per transaction" }, @@ -5610,6 +5628,9 @@ "shieldEntryModalSupport": { "message": "Priority customer support" }, + "shieldEstimatedChangesMonthlyTooltip": { + "message": "Authorize up to $$1 for the full year. You'll be billed $$2 each month, not the full amount now." + }, "shieldPlanAnnual": { "message": "Annual" }, @@ -7267,6 +7288,9 @@ "transactionSettings": { "message": "Transaction settings" }, + "transactionShield": { + "message": "Transaction Shield" + }, "transactionSubmitted": { "message": "Transaction submitted with estimated gas fee of $1 at $2." }, @@ -7708,12 +7732,18 @@ "message": "Wrong password", "description": "Displayed when the user enters an incorrect password" }, + "year": { + "message": "year" + }, "yes": { "message": "Yes" }, "you": { "message": "You" }, + "youApprove": { + "message": "You approve" + }, "youDeclinedTheTransaction": { "message": "You declined the transaction." }, diff --git a/app/_locales/en_GB/messages.json b/app/_locales/en_GB/messages.json index 5fd9282994d3..f5308396f291 100644 --- a/app/_locales/en_GB/messages.json +++ b/app/_locales/en_GB/messages.json @@ -595,6 +595,9 @@ "message": "$1 and $2", "description": "$1 is the first item, $2 is the second item. Used in Snap Install Warning modal." }, + "annual": { + "message": "Annual" + }, "appDescription": { "message": "The world's most trusted crypto wallet", "description": "The description of the application" @@ -2429,6 +2432,9 @@ "message": "Error with $1", "description": "$1 represents the name of the snap" }, + "estimatedChanges": { + "message": "Estimated changes" + }, "estimatedFee": { "message": "Estimated fee" }, @@ -2584,6 +2590,9 @@ "form": { "message": "form" }, + "freeSevenDayTrial": { + "message": "Free 7-day trial" + }, "from": { "message": "From" }, @@ -3507,6 +3516,12 @@ "missingSettingRequest": { "message": "Request here" }, + "month": { + "message": "month" + }, + "monthly": { + "message": "Monthly" + }, "more": { "message": "more" }, @@ -5588,6 +5603,9 @@ "settingsSubHeadingSignaturesAndTransactions": { "message": "Signature and transaction requests" }, + "shieldConfirmMembership": { + "message": "Confirm membership" + }, "shieldEntryModalAssetCoverage": { "message": "$10,000 asset coverage per transaction" }, @@ -5610,6 +5628,9 @@ "shieldEntryModalSupport": { "message": "Priority customer support" }, + "shieldEstimatedChangesMonthlyTooltip": { + "message": "Authorize up to $$1 for the full year. You'll be billed $$2 each month, not the full amount now." + }, "shieldPlanAnnual": { "message": "Annual" }, @@ -7267,6 +7288,9 @@ "transactionSettings": { "message": "Transaction settings" }, + "transactionShield": { + "message": "Transaction Shield" + }, "transactionSubmitted": { "message": "Transaction submitted with estimated gas fee of $1 at $2." }, @@ -7708,12 +7732,18 @@ "message": "Wrong password", "description": "Displayed when the user enters an incorrect password" }, + "year": { + "message": "year" + }, "yes": { "message": "Yes" }, "you": { "message": "You" }, + "youApprove": { + "message": "You approve" + }, "youDeclinedTheTransaction": { "message": "You declined the transaction." }, diff --git a/package.json b/package.json index 1ad19ecc46ad..bf3773116869 100644 --- a/package.json +++ b/package.json @@ -360,7 +360,7 @@ "@metamask/solana-wallet-standard": "^0.6.0", "@metamask/streams": "^0.4.0", "@metamask/subscription-controller": "^0.5.0", - "@metamask/transaction-controller": "^60.2.0", + "@metamask/transaction-controller": "^60.6.0", "@metamask/user-operation-controller": "^39.0.0", "@metamask/utils": "^11.4.2", "@ngraveio/bc-ur": "^1.1.13", diff --git a/shared/lib/confirmation.utils.ts b/shared/lib/confirmation.utils.ts index 9dfe26eb04a1..3c2e0a0325aa 100644 --- a/shared/lib/confirmation.utils.ts +++ b/shared/lib/confirmation.utils.ts @@ -15,13 +15,14 @@ const REDESIGN_USER_TRANSACTION_TYPES = [ TransactionType.contractInteraction, TransactionType.deployContract, TransactionType.revokeDelegation, + TransactionType.shieldSubscriptionApprove, + TransactionType.simpleSend, TransactionType.tokenMethodApprove, TransactionType.tokenMethodIncreaseAllowance, + TransactionType.tokenMethodSafeTransferFrom, TransactionType.tokenMethodSetApprovalForAll, TransactionType.tokenMethodTransfer, TransactionType.tokenMethodTransferFrom, - TransactionType.tokenMethodSafeTransferFrom, - TransactionType.simpleSend, ]; /** List of transaction types that support the redesigned confirmation flow for developers */ diff --git a/ui/hooks/subscription/useSubscriptionPricing.ts b/ui/hooks/subscription/useSubscriptionPricing.ts index 40d7dd9f7807..d201da0a629c 100644 --- a/ui/hooks/subscription/useSubscriptionPricing.ts +++ b/ui/hooks/subscription/useSubscriptionPricing.ts @@ -30,7 +30,12 @@ export type TokenWithApprovalAmount = ( | AssetWithDisplayData | AssetWithDisplayData ) & { - approvalAmount: string; + approvalAmount: { + approveAmount: string; + chainId: Hex; + paymentAddress: Hex; + paymentTokenAddress: Hex; + }; }; export const useAvailableTokenBalances = (params: { diff --git a/ui/pages/confirmations/components/confirm/header/advanced-details-button.tsx b/ui/pages/confirmations/components/confirm/header/advanced-details-button.tsx index 0337a69787b1..3c1106816732 100644 --- a/ui/pages/confirmations/components/confirm/header/advanced-details-button.tsx +++ b/ui/pages/confirmations/components/confirm/header/advanced-details-button.tsx @@ -1,3 +1,7 @@ +import { + TransactionMeta, + TransactionType, +} from '@metamask/transaction-controller'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { @@ -14,11 +18,13 @@ import { } from '../../../../../helpers/constants/design-system'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { setConfirmationAdvancedDetailsOpen } from '../../../../../store/actions'; +import { useConfirmContext } from '../../../context/confirm'; import { selectConfirmationAdvancedDetailsOpen } from '../../../selectors/preferences'; export const AdvancedDetailsButton = () => { const t = useI18nContext(); const dispatch = useDispatch(); + const { currentConfirmation } = useConfirmContext(); const showAdvancedDetails = useSelector( selectConfirmationAdvancedDetailsOpen, @@ -37,6 +43,13 @@ export const AdvancedDetailsButton = () => { } borderRadius={BorderRadius.MD} marginRight={1} + // hiding through visibility instead of rendering conditionally so the + // header layout is not affected + style={ + currentConfirmation?.type === TransactionType.shieldSubscriptionApprove + ? { visibility: 'hidden' } + : {} + } > { @@ -106,7 +107,7 @@ const Header = () => { // addresses as well. const isConfirmationWithNewHeader = currentConfirmation?.type && - CONFIRMATIONS_WITH_NEW_HEADER.includes(currentConfirmation.type); + CONFIRMATIONS_WITH_ALT_HEADER.includes(currentConfirmation.type); const isWalletInitiated = (currentConfirmation as TransactionMeta)?.origin === ORIGIN_METAMASK; if (isConfirmationWithNewHeader && isWalletInitiated) { diff --git a/ui/pages/confirmations/components/confirm/header/wallet-initiated-header.tsx b/ui/pages/confirmations/components/confirm/header/wallet-initiated-header.tsx index 809c56032129..93df51b8edf3 100644 --- a/ui/pages/confirmations/components/confirm/header/wallet-initiated-header.tsx +++ b/ui/pages/confirmations/components/confirm/header/wallet-initiated-header.tsx @@ -6,8 +6,9 @@ import React, { useCallback } from 'react'; import { useDispatch } from 'react-redux'; import { useHistory } from 'react-router-dom'; -import { AssetType } from '../../../../../../shared/constants/transaction'; +import { useNavigate } from 'react-router-dom-v5-compat'; import { MetaMetricsEventLocation } from '../../../../../../shared/constants/metametrics'; +import { AssetType } from '../../../../../../shared/constants/transaction'; import { Box, ButtonIcon, @@ -27,12 +28,13 @@ import { TextColor, TextVariant, } from '../../../../../helpers/constants/design-system'; +import { SHIELD_PLAN_ROUTE } from '../../../../../helpers/constants/routes'; import { useI18nContext } from '../../../../../hooks/useI18nContext'; import { showSendTokenPage } from '../../../../../store/actions'; -import { useConfirmActions } from '../../../hooks/useConfirmActions'; import { useConfirmContext } from '../../../context/confirm'; -import { navigateToSendRoute } from '../../../utils/send'; +import { useConfirmActions } from '../../../hooks/useConfirmActions'; import { useRedesignedSendFlow } from '../../../hooks/useRedesignedSendFlow'; +import { navigateToSendRoute } from '../../../utils/send'; import { AdvancedDetailsButton } from './advanced-details-button'; export const WalletInitiatedHeader = () => { @@ -42,8 +44,17 @@ export const WalletInitiatedHeader = () => { const { enabled: isSendRedesignEnabled } = useRedesignedSendFlow(); const { onCancel } = useConfirmActions(); const { currentConfirmation } = useConfirmContext(); + const navigate = useNavigate(); const handleBackButtonClick = useCallback(async () => { + if ( + currentConfirmation.type === TransactionType.shieldSubscriptionApprove + ) { + onCancel({ location: MetaMetricsEventLocation.Confirmation }); + navigate(SHIELD_PLAN_ROUTE); + return; + } + const { id } = currentConfirmation; const isNativeSend = @@ -80,7 +91,14 @@ export const WalletInitiatedHeader = () => { dispatch(clearConfirmTransaction()); dispatch(showSendTokenPage()); navigateToSendRoute(history, isSendRedesignEnabled); - }, [currentConfirmation, dispatch, history, isSendRedesignEnabled, onCancel]); + }, [ + currentConfirmation, + dispatch, + history, + isSendRedesignEnabled, + navigate, + onCancel, + ]); return ( { color={IconColor.iconDefault} /> - {t('review')} + {currentConfirmation.type === TransactionType.shieldSubscriptionApprove + ? t('shieldConfirmMembership') + : t('review')} diff --git a/ui/pages/confirmations/components/confirm/info/info.tsx b/ui/pages/confirmations/components/confirm/info/info.tsx index afbe7e3b0447..ec73eae9a5e6 100644 --- a/ui/pages/confirmations/components/confirm/info/info.tsx +++ b/ui/pages/confirmations/components/confirm/info/info.tsx @@ -1,17 +1,18 @@ import { TransactionType } from '@metamask/transaction-controller'; import React, { useMemo } from 'react'; +import { isGatorPermissionsFeatureEnabled } from '../../../../../../shared/modules/environment'; +import { useTrustSignalMetrics } from '../../../../trust-signals/hooks/useTrustSignalMetrics'; import { useConfirmContext } from '../../../context/confirm'; -import { SignatureRequestType } from '../../../types/confirm'; import { useSmartTransactionFeatureFlags } from '../../../hooks/useSmartTransactionFeatureFlags'; import { useTransactionFocusEffect } from '../../../hooks/useTransactionFocusEffect'; -import { useTrustSignalMetrics } from '../../../../trust-signals/hooks/useTrustSignalMetrics'; -import { isGatorPermissionsFeatureEnabled } from '../../../../../../shared/modules/environment'; +import { SignatureRequestType } from '../../../types/confirm'; import ApproveInfo from './approve/approve'; import BaseTransactionInfo from './base-transaction-info/base-transaction-info'; import NativeTransferInfo from './native-transfer/native-transfer'; import NFTTokenTransferInfo from './nft-token-transfer/nft-token-transfer'; import PersonalSignInfo from './personal-sign/personal-sign'; import SetApprovalForAllInfo from './set-approval-for-all-info/set-approval-for-all-info'; +import ShieldSubscriptionApproveInfo from './shield-subscription-approve/shield-subscription-approve'; import TokenTransferInfo from './token-transfer/token-transfer'; import TypedSignV1Info from './typed-sign-v1/typed-sign-v1'; import TypedSignInfo from './typed-sign/typed-sign'; @@ -34,6 +35,8 @@ const Info = () => { [TransactionType.personalSign]: () => PersonalSignInfo, [TransactionType.revokeDelegation]: () => BaseTransactionInfo, [TransactionType.simpleSend]: () => NativeTransferInfo, + [TransactionType.shieldSubscriptionApprove]: () => + ShieldSubscriptionApproveInfo, [TransactionType.signTypedData]: () => { const signatureRequest = currentConfirmation as SignatureRequestType; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/account-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/account-details.test.tsx.snap new file mode 100644 index 000000000000..68f55eafbb3d --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/account-details.test.tsx.snap @@ -0,0 +1,32 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccountDetails renders correctly 1`] = ` +
+
+
+
+
+

+ Account +

+
+
+
+ 0xFrom +
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/estimated-changes.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/estimated-changes.test.tsx.snap new file mode 100644 index 000000000000..87d1d318f12f --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/estimated-changes.test.tsx.snap @@ -0,0 +1,132 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EstimatedChanges renders annual plan correctly without tooltip 1`] = ` +
+
+
+
+
+

+ Estimated changes +

+
+
+
+
+
+
+

+ You approve +

+
+
+
+ + 80 + + + 0xToken + +
+
+
+
+`; + +exports[`EstimatedChanges renders monthly plan correctly with tooltip 1`] = ` +
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+
+
+

+ You approve +

+
+
+
+ + 96 + + + 0xToken + +
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve-loader.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve-loader.test.tsx.snap new file mode 100644 index 000000000000..1e4cf1e36b5e --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve-loader.test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShieldSubscriptionApproveLoader renders correctly 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve.test.tsx.snap new file mode 100644 index 000000000000..ff36f9624527 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/shield-subscription-approve.test.tsx.snap @@ -0,0 +1,274 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShieldSubscriptionApproveInfo renders correctly 1`] = ` +
+
+
+
+
+

+ Transaction Shield +

+

+ $8/month (Monthly) +

+
+
+

+ Free 7-day trial +

+
+
+
+
+
+
+
+

+ Estimated changes +

+
+
+ +
+
+
+
+
+
+
+
+

+ You approve +

+
+
+
+ + 96 + +
+
+ +

+ 0x07614...3ad68 +

+
+
+
+
+
+
+
+
+
+

+ Account +

+
+
+
+
+
+ +

+ 0x2e0D7...5d09B +

+
+
+
+
+
+
+
+
+
+
+

+ Network fee +

+
+
+ +
+
+
+
+
+
+
+
+

+ +

+
+
+
+
+
+
+

+ Speed +

+
+
+
+
+

+ 🦊 Market +

+

+ + ~ + 0 sec + +

+
+
+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/subscription-details.test.tsx.snap b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/subscription-details.test.tsx.snap new file mode 100644 index 000000000000..78fa51bcdeed --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/__snapshots__/subscription-details.test.tsx.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SubscriptionDetails renders annual plan with trial correctly 1`] = ` +
+
+
+
+

+ Transaction Shield +

+

+ $80/year (Annual) +

+
+
+

+ Free 7-day trial +

+
+
+
+
+`; + +exports[`SubscriptionDetails renders monthly plan without trial correctly 1`] = ` +
+
+
+
+

+ Transaction Shield +

+

+ $8/month (Monthly) +

+
+
+
+
+`; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.test.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.test.tsx new file mode 100644 index 000000000000..4bd9ec614cbb --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.test.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithProvider } from '../../../../../../../test/lib/render-helpers'; +import { AccountDetails } from './account-details'; + +jest.mock('../../../../../../components/app/confirm/info/row/address', () => ({ + ConfirmInfoRowAddress: ({ address }: { address: string }) => ( +
{address}
+ ), +})); + +describe('AccountDetails', () => { + it('renders correctly', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.tsx new file mode 100644 index 000000000000..f74a59b010fb --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/account-details.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { + ConfirmInfoRow, + ConfirmInfoRowAddress, +} from '../../../../../../components/app/confirm/info/row'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { useI18nContext } from '../../../../../../hooks/useI18nContext'; + +export const AccountDetails = ({ + accountAddress, + chainId, +}: { + accountAddress: string; + chainId: string; +}) => { + const t = useI18nContext(); + + return ( + + + + + + ); +}; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.test.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.test.tsx new file mode 100644 index 000000000000..5e4a626965e8 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithProvider } from '../../../../../../../test/lib/render-helpers'; +import { EstimatedChanges } from './estimated-changes'; + +jest.mock('../../../../../../components/app/name/name', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + default: ({ value }: { value: string }) => {value}, +})); + +describe('EstimatedChanges', () => { + it('renders monthly plan correctly with tooltip', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders annual plan correctly without tooltip', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx new file mode 100644 index 000000000000..8e900bedcd77 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/estimated-changes.tsx @@ -0,0 +1,54 @@ +import { + Box, + BoxFlexDirection +} from '@metamask/design-system-react'; +import { NameType } from '@metamask/name-controller'; +import { Hex } from '@metamask/utils'; +import React from 'react'; +import { ConfirmInfoRow } from '../../../../../../components/app/confirm/info/row'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import Name from '../../../../../../components/app/name'; +import { TextColor } from '../../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../../hooks/useI18nContext'; + +export const EstimatedChanges = ({ + approvalAmount, + tokenAddress, + chainId, +}: { + approvalAmount: string; + tokenAddress: Hex; + chainId: Hex; +}) => { + const t = useI18nContext(); + + const isMonthlySubscription = approvalAmount === '96'; + + return ( + + + + + {approvalAmount} + + + + + ); +}; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.test.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.test.tsx new file mode 100644 index 000000000000..51c5166d976e --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.test.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithProvider } from '../../../../../../../test/lib/render-helpers'; +import ShieldSubscriptionApproveLoader from './shield-subscription-approve-loader'; + +describe('ShieldSubscriptionApproveLoader', () => { + it('renders correctly', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.tsx new file mode 100644 index 000000000000..d5ec1ba994b2 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve-loader.tsx @@ -0,0 +1,46 @@ +import { Box } from '@metamask/design-system-react'; +import React from 'react'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { Skeleton } from '../../../../../../components/component-library/skeleton'; + +const ShieldSubscriptionApproveLoader = () => { + return ( + + {/* Subscription Details */} + + + + + + + + + {/* Estimated Changes */} + + + + + + + + + {/* Account Details */} + + + + + + {/* Gas Fees */} + + + + + + + + + + ); +}; + +export default ShieldSubscriptionApproveLoader; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.test.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.test.tsx new file mode 100644 index 000000000000..12859d7e0316 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.test.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockApproveConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithConfirmContextProvider } from '../../../../../../../test/lib/confirmations/render-helpers'; +import ShieldSubscriptionApproveInfo from './shield-subscription-approve'; + +jest.mock('../hooks/useDecodedTransactionData', () => ({ + useDecodedTransactionData: jest.fn(() => ({ + pending: false, + value: { + data: [ + { + params: [{ name: 'value', value: '96000000000000000000' }], + }, + ], + }, + })), +})); + +jest.mock('../../../../hooks/useAssetDetails', () => ({ + useAssetDetails: jest.fn(() => ({ decimals: 18 })), +})); + +jest.mock('../../../../../../hooks/subscription/useSubscription', () => ({ + useUserSubscriptions: jest.fn(() => ({ + trialedProducts: [], + loading: false, + })), +})); + +jest.mock( + '../../../../../../components/app/alert-system/contexts/alertMetricsContext', + () => ({ + useAlertMetrics: jest.fn(() => ({ + trackAlertMetrics: jest.fn(), + })), + }), +); + +jest.mock('../../../../../../store/actions', () => ({ + ...jest.requireActual('../../../../../../store/actions'), + getGasFeeTimeEstimate: jest.fn().mockResolvedValue({ + lowerTimeBound: 0, + upperTimeBound: 60000, + }), +})); + +describe('ShieldSubscriptionApproveInfo', () => { + it('renders correctly', () => { + const state = getMockApproveConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithConfirmContextProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.tsx new file mode 100644 index 000000000000..76e03425280b --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/shield-subscription-approve.tsx @@ -0,0 +1,68 @@ +import { Box } from '@metamask/design-system-react'; +import { PRODUCT_TYPES } from '@metamask/subscription-controller'; +import { TransactionMeta } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import React from 'react'; +import { useUserSubscriptions } from '../../../../../../hooks/subscription/useSubscription'; +import { useConfirmContext } from '../../../../context/confirm'; +import { useAssetDetails } from '../../../../hooks/useAssetDetails'; +import { useDecodedTransactionData } from '../hooks/useDecodedTransactionData'; +import { GasFeesSection } from '../shared/gas-fees-section/gas-fees-section'; +import { AccountDetails } from './account-details'; +import { EstimatedChanges } from './estimated-changes'; +import ShieldSubscriptionApproveLoader from './shield-subscription-approve-loader'; +import { SubscriptionDetails } from './subscription-details'; + +const ShieldSubscriptionApproveInfo = () => { + const { currentConfirmation: transactionMeta } = + useConfirmContext(); + const decodeResponse = useDecodedTransactionData({ + data: transactionMeta.txParams.data as Hex, + to: transactionMeta.txParams.to as Hex, + }); + const decodedApprovalAmount = decodeResponse?.value?.data[0].params.find( + (param) => param.name === 'value', + )?.value; + const { decimals } = useAssetDetails( + transactionMeta.txParams.to, + transactionMeta.txParams.from, + transactionMeta.txParams.data, + transactionMeta.chainId, + ); + const approvalAmountInWeiBn = new BigNumber(decodedApprovalAmount ?? 0); + const approvalAmount = approvalAmountInWeiBn + .div(10 ** (decimals ?? 0)) + .toFixed(); + + const { trialedProducts, loading: subscriptionsLoading } = + useUserSubscriptions(); + const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); + + const isLoading = + subscriptionsLoading || decodeResponse?.pending || !decimals; + if (isLoading) { + return ; + } + + return ( + + + + + + + ); +}; + +export default ShieldSubscriptionApproveInfo; diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.test.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.test.tsx new file mode 100644 index 000000000000..826c68db15ca --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.test.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import configureMockStore from 'redux-mock-store'; +import { getMockConfirmState } from '../../../../../../../test/data/confirmations/helper'; +import { renderWithProvider } from '../../../../../../../test/lib/render-helpers'; +import { SubscriptionDetails } from './subscription-details'; + +describe('SubscriptionDetails', () => { + it('renders annual plan with trial correctly', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); + + it('renders monthly plan without trial correctly', () => { + const state = getMockConfirmState(); + const mockStore = configureMockStore([])(state); + const { container } = renderWithProvider( + , + mockStore, + ); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.tsx b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.tsx new file mode 100644 index 000000000000..2719be0ae648 --- /dev/null +++ b/ui/pages/confirmations/components/confirm/info/shield-subscription-approve/subscription-details.tsx @@ -0,0 +1,67 @@ +import { Box, BoxAlignItems, BoxFlexDirection, BoxJustifyContent } from '@metamask/design-system-react'; +import React from 'react'; +import { ConfirmInfoSection } from '../../../../../../components/app/confirm/info/row/section'; +import { Text } from '../../../../../../components/component-library'; +import { + TextColor, + TextVariant +} from '../../../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../../../hooks/useI18nContext'; + +export const SubscriptionDetails = ({ + approvalAmount, + showTrial, +}: { + approvalAmount: string; + showTrial: boolean; +}) => { + const t = useI18nContext(); + + const isMonthlySubscription = approvalAmount === '96'; + + const planDetailsStr = isMonthlySubscription + ? `$8/${t('month')} (${t('monthly')})` + : `$80/${t('year')} (${t('annual')})`; + + return ( + + + + + {t('transactionShield')} + + + {planDetailsStr} + + + {showTrial && ( + + + {t('freeSevenDayTrial')} + + + )} + + + ); +}; diff --git a/ui/pages/confirmations/send-legacy/send.constants.js b/ui/pages/confirmations/send-legacy/send.constants.js index 3a57d950a945..10b1d3834765 100644 --- a/ui/pages/confirmations/send-legacy/send.constants.js +++ b/ui/pages/confirmations/send-legacy/send.constants.js @@ -22,6 +22,7 @@ const MIN_GAS_TOTAL = new Numeric(MIN_GAS_LIMIT_HEX, 16) .toPrefixedHexString(); const TOKEN_TRANSFER_FUNCTION_SIGNATURE = '0xa9059cbb'; +const TOKEN_APPROVAL_FUNCTION_SIGNATURE = '0x095ea7b3'; const NFT_TRANSFER_FROM_FUNCTION_SIGNATURE = '0x23b872dd'; const NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE = '0xf242432a'; @@ -67,6 +68,7 @@ export { REQUIRED_ERROR, CONFUSING_ENS_ERROR, TOKEN_TRANSFER_FUNCTION_SIGNATURE, + TOKEN_APPROVAL_FUNCTION_SIGNATURE, NFT_TRANSFER_FROM_FUNCTION_SIGNATURE, NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE, RECIPIENT_TYPES, diff --git a/ui/pages/confirmations/send-legacy/send.utils.js b/ui/pages/confirmations/send-legacy/send.utils.js index 6b16a117fa93..f2f8cf487274 100644 --- a/ui/pages/confirmations/send-legacy/send.utils.js +++ b/ui/pages/confirmations/send-legacy/send.utils.js @@ -5,16 +5,19 @@ import { isHexString } from '@metamask/utils'; import { addHexPrefix } from '../../../../app/scripts/lib/util'; import { TokenStandard } from '../../../../shared/constants/transaction'; import { Numeric } from '../../../../shared/modules/Numeric'; +import { BURN_ADDRESS } from '../../../../shared/modules/hexstring-utils'; import { TOKEN_TRANSFER_FUNCTION_SIGNATURE, NFT_TRANSFER_FROM_FUNCTION_SIGNATURE, NFT_SAFE_TRANSFER_FROM_FUNCTION_SIGNATURE, + TOKEN_APPROVAL_FUNCTION_SIGNATURE, } from './send.constants'; export { addGasBuffer, getAssetTransferData, generateERC20TransferData, + generateERC20ApprovalData, generateERC721TransferData, generateERC1155TransferData, isBalanceSufficient, @@ -155,6 +158,27 @@ function generateERC1155TransferData({ ); } +function generateERC20ApprovalData({ + spenderAddress = BURN_ADDRESS, + amount = '0x0', +}) { + if (!spenderAddress) { + return undefined; + } + return ( + TOKEN_APPROVAL_FUNCTION_SIGNATURE + + Array.prototype.map + .call( + encode( + ['address', 'uint256'], + [addHexPrefix(spenderAddress), addHexPrefix(amount)], + ), + (x) => `00${x.toString(16)}`.slice(-2), + ) + .join('') + ); +} + function getAssetTransferData({ sendToken, fromAddress, toAddress, amount }) { switch (sendToken.standard) { case TokenStandard.ERC721: diff --git a/ui/pages/confirmations/send-legacy/send.utils.test.js b/ui/pages/confirmations/send-legacy/send.utils.test.js index d7fe0d2b79b8..33a3fa0097e3 100644 --- a/ui/pages/confirmations/send-legacy/send.utils.test.js +++ b/ui/pages/confirmations/send-legacy/send.utils.test.js @@ -3,6 +3,7 @@ import { encode } from '@metamask/abi-utils'; import { TokenStandard } from '../../../../shared/constants/transaction'; import { generateERC20TransferData, + generateERC20ApprovalData, isBalanceSufficient, isTokenBalanceSufficient, ellipsify, @@ -53,6 +54,40 @@ describe('send utils', () => { }); }); + describe('generateERC20ApprovalData()', () => { + it('should return undefined if not passed a spender address', () => { + expect( + generateERC20ApprovalData({ + spenderAddress: null, + amount: '0xa', + }), + ).toBeUndefined(); + }); + + it('should call abi-utils.encode with the correct params', () => { + encode.mockClear(); + generateERC20ApprovalData({ + spenderAddress: 'mockAddress', + amount: 'ab', + }); + expect(encode.mock.calls[0].toString()).toStrictEqual( + [ + ['address', 'uint256'], + ['0xmockAddress', '0xab'], + ].toString(), + ); + }); + + it('should return encoded token approval data', () => { + expect( + generateERC20ApprovalData({ + spenderAddress: 'mockAddress', + amount: '0xa', + }), + ).toStrictEqual('0x095ea7b3'); + }); + }); + describe('isBalanceSufficient()', () => { it('should correctly sum the appropriate currencies and ensure that balance is greater', () => { const result = isBalanceSufficient({ diff --git a/ui/pages/shield-plan/shield-plan.tsx b/ui/pages/shield-plan/shield-plan.tsx index c8c25fdbed13..1e1d5d55ed33 100644 --- a/ui/pages/shield-plan/shield-plan.tsx +++ b/ui/pages/shield-plan/shield-plan.tsx @@ -1,6 +1,3 @@ -import log from 'loglevel'; -import React, { useEffect, useMemo, useState } from 'react'; -import classnames from 'classnames'; import { PAYMENT_TYPES, PaymentType, @@ -9,14 +6,42 @@ import { RECURRING_INTERVALS, RecurringInterval, } from '@metamask/subscription-controller'; -import { useDispatch } from 'react-redux'; +import { TransactionType } from '@metamask/transaction-controller'; +import { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import classnames from 'classnames'; +import React, { useEffect, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom-v5-compat'; +import { + CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, + NETWORK_TO_NAME_MAP, +} from '../../../shared/constants/network'; +import { decimalToHex } from '../../../shared/modules/conversion.utils'; +import { + AvatarNetwork, + AvatarNetworkSize, + AvatarToken, + BadgeWrapper, + Box, + BoxProps, + Button, + ButtonIcon, + ButtonIconSize, + ButtonSize, + ButtonVariant, + Icon, + IconName, + IconSize, + Text, +} from '../../components/component-library'; import { Content, Footer, Header, Page, } from '../../components/multichain/pages/page'; +import LoadingScreen from '../../components/ui/loading-screen'; import { AlignItems, BackgroundColor, @@ -32,29 +57,14 @@ import { TextVariant, } from '../../helpers/constants/design-system'; import { - ButtonIconSize, - ButtonIcon, - IconName, - Box, - Text, - BoxProps, - BadgeWrapper, - AvatarNetwork, - AvatarNetworkSize, - AvatarToken, - Icon, - IconSize, - ButtonSize, - ButtonVariant, - Button, -} from '../../components/component-library'; -import { useI18nContext } from '../../hooks/useI18nContext'; - + CONFIRM_TRANSACTION_ROUTE, + SETTINGS_ROUTE, + TRANSACTION_SHIELD_ROUTE, +} from '../../helpers/constants/routes'; import { - CHAIN_ID_TO_NETWORK_IMAGE_URL_MAP, - NETWORK_TO_NAME_MAP, -} from '../../../shared/constants/network'; -import LoadingScreen from '../../components/ui/loading-screen'; + useUserSubscriptionByProduct, + useUserSubscriptions, +} from '../../hooks/subscription/useSubscription'; import { TokenWithApprovalAmount, useAvailableTokenBalances, @@ -62,16 +72,12 @@ import { useSubscriptionPricing, useSubscriptionProductPlans, } from '../../hooks/subscription/useSubscriptionPricing'; -import { startSubscriptionWithCard } from '../../store/actions'; -import { - useUserSubscriptionByProduct, - useUserSubscriptions, -} from '../../hooks/subscription/useSubscription'; -import { - SETTINGS_ROUTE, - TRANSACTION_SHIELD_ROUTE, -} from '../../helpers/constants/routes'; import { useAsyncCallback } from '../../hooks/useAsync'; +import { useI18nContext } from '../../hooks/useI18nContext'; +import { selectNetworkConfigurationByChainId } from '../../selectors'; +import { getInternalAccountBySelectedAccountGroupAndCaip } from '../../selectors/multichain-accounts/account-tree'; +import { addTransaction, startSubscriptionWithCard } from '../../store/actions'; +import { generateERC20ApprovalData } from '../confirmations/send-legacy/send.utils'; import { ShieldPaymentModal } from './shield-payment-modal'; import { Plan } from './types'; import { getProductPrice } from './utils'; @@ -80,7 +86,10 @@ const ShieldPlan = () => { const navigate = useNavigate(); const t = useI18nContext(); const dispatch = useDispatch(); - + const evmInternalAccount = useSelector((state) => + // Account address will be the same for all EVM accounts + getInternalAccountBySelectedAccountGroupAndCaip(state, 'eip155:1'), + ); const { subscriptions, trialedProducts, @@ -140,6 +149,13 @@ const ShieldPlan = () => { >(() => { return availableTokenBalances[0]; }); + const networkConfiguration = useSelector((state) => + selectNetworkConfigurationByChainId(state, selectedToken?.chainId as Hex), + ); + const networkClientId = + networkConfiguration?.rpcEndpoints[ + networkConfiguration.defaultRpcEndpointIndex ?? 0 + ]?.networkClientId; // set selected token to the first available token if no token is selected useEffect(() => { @@ -158,11 +174,54 @@ const ShieldPlan = () => { recurringInterval: selectedPlan, }), ); - } else { - log.error('Crypto payment method is not supported at the moment'); - throw new Error('Crypto payment method is not supported at the moment'); + } else if (selectedPaymentMethod === PAYMENT_TYPES.byCrypto) { + const approvalAmount = new BigNumber( + selectedToken?.approvalAmount?.approveAmount ?? '0', + ); + const balance = new BigNumber(selectedToken?.balance ?? '0'); + const balanceInWei = balance.mul(10 ** (selectedToken?.decimals ?? 18)); + const userHasEnoughBalance = balanceInWei.gte(approvalAmount); + + if (!userHasEnoughBalance) { + throw new Error('Insufficient balance'); + } + + if (!selectedToken) { + throw new Error('No token selected'); + } + + const spenderAddress = subscriptionPricing?.paymentMethods + ?.find((method) => method.type === PAYMENT_TYPES.byCrypto) + ?.chains?.find( + (chain) => chain.chainId === selectedToken?.chainId, + )?.paymentAddress; + const approvalData = generateERC20ApprovalData({ + spenderAddress, + amount: decimalToHex(selectedToken.approvalAmount.approveAmount), + }); + const transactionParams = { + from: evmInternalAccount?.address as Hex, + to: selectedToken.address as Hex, + value: '0x0', + data: approvalData, + }; + const transactionOptions = { + type: TransactionType.shieldSubscriptionApprove, + networkClientId: networkClientId as string, + }; + await addTransaction(transactionParams, transactionOptions); + navigate(CONFIRM_TRANSACTION_ROUTE); } - }, [selectedPlan, selectedPaymentMethod, dispatch, isTrialed]); + }, [ + dispatch, + evmInternalAccount?.address, + isTrialed, + navigate, + networkClientId, + selectedPaymentMethod, + selectedPlan, + selectedToken, + ]); const loading = subscriptionsLoading || diff --git a/yarn.lock b/yarn.lock index 1cc0f7bcbda4..f44042bd772d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7795,6 +7795,42 @@ __metadata: languageName: node linkType: hard +"@metamask/transaction-controller@npm:^60.6.0": + version: 60.6.0 + resolution: "@metamask/transaction-controller@npm:60.6.0" + dependencies: + "@ethereumjs/common": "npm:^4.4.0" + "@ethereumjs/tx": "npm:^5.4.0" + "@ethereumjs/util": "npm:^9.1.0" + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@ethersproject/wallet": "npm:^5.7.0" + "@metamask/base-controller": "npm:^8.4.0" + "@metamask/controller-utils": "npm:^11.14.0" + "@metamask/eth-query": "npm:^4.0.0" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/nonce-tracker": "npm:^6.0.0" + "@metamask/rpc-errors": "npm:^7.0.2" + "@metamask/utils": "npm:^11.8.1" + async-mutex: "npm:^0.5.0" + bn.js: "npm:^5.2.1" + eth-method-registry: "npm:^4.0.0" + fast-json-patch: "npm:^3.1.1" + lodash: "npm:^4.17.21" + uuid: "npm:^8.3.2" + peerDependencies: + "@babel/runtime": ^7.0.0 + "@metamask/accounts-controller": ^33.0.0 + "@metamask/approval-controller": ^7.0.0 + "@metamask/eth-block-tracker": ">=9" + "@metamask/gas-fee-controller": ^24.0.0 + "@metamask/network-controller": ^24.0.0 + "@metamask/remote-feature-flag-controller": ^1.5.0 + checksum: 10/c23f146d625fc92093361785950cd950421615f91e781950a66e61f8c689806e61c7db03bd3cecf7b44fa4406c12204ad7e69f9b40c18062f77f14175ee7bf2a + languageName: node + linkType: hard + "@metamask/user-operation-controller@npm:^39.0.0": version: 39.0.0 resolution: "@metamask/user-operation-controller@npm:39.0.0" @@ -32219,7 +32255,7 @@ __metadata: "@metamask/test-dapp": "npm:9.3.0" "@metamask/test-dapp-multichain": "npm:^0.17.0" "@metamask/test-dapp-solana": "npm:^0.3.1" - "@metamask/transaction-controller": "npm:^60.2.0" + "@metamask/transaction-controller": "npm:^60.6.0" "@metamask/user-operation-controller": "npm:^39.0.0" "@metamask/utils": "npm:^11.4.2" "@ngraveio/bc-ur": "npm:^1.1.13"