From 9991cd3b872ce06489d58e5329c1895310facee9 Mon Sep 17 00:00:00 2001 From: Albina Nikiforova Date: Thu, 27 Feb 2025 18:43:21 +0100 Subject: [PATCH] refactor(suite): fee component --- .../src/components/FeeRate/FeeRate.tsx | 27 +- .../TransactionReviewSummary.tsx | 7 +- .../TxDetailModal/BasicTxDetails.tsx | 2 + .../CancelTransaction/CancelTransaction.tsx | 6 +- .../TxDetailModal/ChangeFee/ChangeFee.tsx | 2 +- .../src/components/wallet/Fees/CustomFee.tsx | 236 -------------- .../wallet/Fees/CustomFee/CurrentFee.tsx | 30 ++ .../wallet/Fees/CustomFee/CustomFee.tsx | 122 +++++++ .../Fees/CustomFee/CustomFeeEthereum.tsx | 125 ++++++++ .../wallet/Fees/CustomFee/CustomFeeMisc.tsx | 100 ++++++ .../Fees/CustomFee/CustomFeeWrapper.tsx | 29 ++ .../wallet/Fees/CustomFee/index.tsx | 7 + .../src/components/wallet/Fees/FeeDetails.tsx | 298 ------------------ .../suite/src/components/wallet/Fees/Fees.tsx | 17 +- .../Fees/StandardFee/BitcoinFeeCards.tsx | 68 ++++ .../Fees/StandardFee/EthereumFeeCards.tsx | 44 +++ .../wallet/Fees/StandardFee/FeeCard.tsx | 45 +++ .../wallet/Fees/StandardFee/MiscFeeCards.tsx | 48 +++ .../wallet/Fees/StandardFee/StandardFee.tsx | 94 ++++++ .../wallet/Fees/StandardFee/index.tsx | 7 + packages/suite/src/support/messages.ts | 4 + suite-common/wallet-config/src/utils.ts | 11 + 22 files changed, 779 insertions(+), 550 deletions(-) delete mode 100644 packages/suite/src/components/wallet/Fees/CustomFee.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeEthereum.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeMisc.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeWrapper.tsx create mode 100644 packages/suite/src/components/wallet/Fees/CustomFee/index.tsx delete mode 100644 packages/suite/src/components/wallet/Fees/FeeDetails.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/BitcoinFeeCards.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/FeeCard.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/MiscFeeCards.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/StandardFee.tsx create mode 100644 packages/suite/src/components/wallet/Fees/StandardFee/index.tsx diff --git a/packages/product-components/src/components/FeeRate/FeeRate.tsx b/packages/product-components/src/components/FeeRate/FeeRate.tsx index 831077754c3..357097698bf 100644 --- a/packages/product-components/src/components/FeeRate/FeeRate.tsx +++ b/packages/product-components/src/components/FeeRate/FeeRate.tsx @@ -1,20 +1,39 @@ -import { NetworkType } from '@suite-common/wallet-config'; +import { NetworkSymbol, NetworkType, hasNetworkSettlementLayer } from '@suite-common/wallet-config'; import { getFeeUnits } from '@suite-common/wallet-utils'; import { BigNumber } from '@trezor/utils'; type FeeRateProps = { feeRate?: string | BigNumber; networkType: NetworkType; + symbol: NetworkSymbol; }; -export const FeeRate = ({ feeRate, networkType }: FeeRateProps) => { +export const FeeRate = ({ feeRate, networkType, symbol }: FeeRateProps) => { if (!feeRate) return null; - const fee = typeof feeRate === 'string' ? new BigNumber(feeRate) : feeRate; + let fee; + + const numOfDecimalPlaces = hasNetworkSettlementLayer(symbol) ? 4 : 2; + const multiplier = Math.pow(10, numOfDecimalPlaces); + switch (networkType) { + case 'ethereum': + fee = (Math.ceil((Number(feeRate) || 0) * multiplier) / multiplier).toFixed( + numOfDecimalPlaces, + ); + break; + case 'bitcoin': + fee = + typeof feeRate === 'string' + ? new BigNumber(feeRate).toFixed(2) + : feeRate.toFixed(2); + break; + default: + fee = typeof feeRate === 'string' ? feeRate : feeRate.toString(); + } return ( - {networkType === 'bitcoin' ? fee.toFixed(2) : fee.toString()}  + {fee}  {getFeeUnits(networkType)} ); diff --git a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx index 5eee69b5801..eecc9cc1e12 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx @@ -46,9 +46,8 @@ export const TransactionReviewSummary = ({ ) as string; const fees = useSelector(state => state.wallet.fees); const locale = useLocales(); - - const network = networks[account.symbol]; const { symbol, accountType, index, networkType } = account; + const network = networks[symbol]; const fee = getFee(networkType, tx); const estimateTime = getEstimatedTime(networkType, fees[account.symbol], tx); @@ -87,11 +86,11 @@ export const TransactionReviewSummary = ({ {': '} - + ) : ( - + )} diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/BasicTxDetails.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/BasicTxDetails.tsx index 62ca5784ef2..7d0901a38cf 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/BasicTxDetails.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/BasicTxDetails.tsx @@ -151,6 +151,7 @@ export const BasicTxDetails = ({ )} @@ -174,6 +175,7 @@ export const BasicTxDetails = ({ diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx index 7c9187d160a..20b8f60b58f 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/CancelTransaction/CancelTransaction.tsx @@ -68,7 +68,11 @@ export const CancelTransaction = ({ tx, selectedAccount }: CancelTransactionProp - + } diff --git a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ChangeFee/ChangeFee.tsx b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ChangeFee/ChangeFee.tsx index 225d33191e0..76493d739b4 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ChangeFee/ChangeFee.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/UserContextModal/TxDetailModal/ChangeFee/ChangeFee.tsx @@ -30,7 +30,7 @@ const ChangeFeeLoaded = (props: ChangeFeeProps) => { const feeRate = networkType === 'bitcoin' && tx.rbfParams?.feeRate !== undefined ? ( - + ) : null; const fee = formatNetworkAmount(tx.fee, tx.symbol); diff --git a/packages/suite/src/components/wallet/Fees/CustomFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee.tsx deleted file mode 100644 index 660d3302215..00000000000 --- a/packages/suite/src/components/wallet/Fees/CustomFee.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { - Control, - FieldErrors, - FieldPath, - UseFormGetValues, - UseFormRegister, - UseFormReturn, - UseFormSetValue, -} from 'react-hook-form'; - -import { NetworkType } from '@suite-common/wallet-config'; -import { FeeInfo, FormState } from '@suite-common/wallet-types'; -import { getFeeUnits, getInputState, isInteger } from '@suite-common/wallet-utils'; -import { - Banner, - Column, - Grid, - Icon, - Note, - Row, - Text, - useMediaQuery, - variables, -} from '@trezor/components'; -import { FeeLevel } from '@trezor/connect'; -import { NumberInput } from '@trezor/product-components'; -import { spacings } from '@trezor/theme'; -import { HELP_CENTER_TRANSACTION_FEES_URL } from '@trezor/urls'; -import { BigNumber } from '@trezor/utils/src/bigNumber'; - -import { Translation } from 'src/components/suite'; -import { LearnMoreButton } from 'src/components/suite/LearnMoreButton'; -import { useSelector, useTranslation } from 'src/hooks/suite'; -import { selectLanguage } from 'src/reducers/suite/suiteReducer'; -import { validateDecimals } from 'src/utils/suite/validation'; - -import { InputError } from '../InputError'; - -const FEE_PER_UNIT = 'feePerUnit'; -const FEE_LIMIT = 'feeLimit'; - -interface CustomFeeProps { - networkType: NetworkType; - feeInfo: FeeInfo; - errors: FieldErrors; - register: UseFormRegister; - control: Control; - setValue: UseFormSetValue; - getValues: UseFormGetValues; - composedFeePerByte: string; -} - -// TODO: revisit with priority fees -const getCurrentFee = (levels: FeeLevel[]) => { - const middleIndex = Math.floor((levels.length - 1) / 2); - - return levels[middleIndex].feePerUnit; -}; - -export const CustomFee = ({ - networkType, - feeInfo, - register, - control, - composedFeePerByte, - ...props -}: CustomFeeProps) => { - const { translationString } = useTranslation(); - const isBelowLaptop = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.LG})`); - - const locale = useSelector(selectLanguage); - - // Type assertion allowing to make the component reusable, see https://stackoverflow.com/a/73624072. - const { getValues, setValue } = props as unknown as UseFormReturn; - const errors = props.errors as unknown as FieldErrors; - - const { maxFee, minFee } = feeInfo; - - const feePerUnitValue = getValues(FEE_PER_UNIT); - const feeUnits = getFeeUnits(networkType); - const estimatedFeeLimit = getValues('estimatedFeeLimit'); - - const feePerUnitError = errors.feePerUnit; - const feeLimitError = errors.feeLimit; - - const useFeeLimit = networkType === 'ethereum'; - const isComposedFeeRateDifferent = - !feePerUnitError && composedFeePerByte && feePerUnitValue !== composedFeePerByte; - let feeDifferenceWarning; - if (isComposedFeeRateDifferent && networkType === 'bitcoin') { - const baseFee = getValues('baseFee'); - feeDifferenceWarning = ( - - {composedFeePerByte} {feeUnits} - - ), - }} - /> - ); - } - - const sharedRules = { - required: translationString('CUSTOM_FEE_IS_NOT_SET'), - // Allow decimals in ETH since GWEI is not a satoshi. - validate: (value: string) => { - if (['bitcoin', 'ethereum'].includes(networkType) && !isInteger(value)) { - return translationString('CUSTOM_FEE_IS_NOT_INTEGER'); - } - }, - }; - const feeLimitRules = { - ...sharedRules, - validate: { - ...sharedRules.validate, - feeLimit: (value: string) => { - const feeBig = new BigNumber(value); - if (estimatedFeeLimit && feeBig.lt(estimatedFeeLimit)) { - return translationString('CUSTOM_FEE_LIMIT_BELOW_RECOMMENDED'); - } - }, - }, - }; - const feeRules = { - ...sharedRules, - validate: { - ...sharedRules.validate, - bitcoinDecimalsLimit: validateDecimals(translationString, { - decimals: 2, - except: networkType !== 'bitcoin', - }), - // GWEI: 9 decimal places. - ethereumDecimalsLimit: validateDecimals(translationString, { - decimals: 9, - except: networkType !== 'ethereum', - }), - range: (value: string) => { - const customFee = new BigNumber(value); - - if (customFee.isGreaterThan(maxFee) || customFee.isLessThan(minFee)) { - return translationString('CUSTOM_FEE_NOT_IN_RANGE', { - minFee: new BigNumber(minFee).toString(), - maxFee: new BigNumber(maxFee).toString(), - }); - } - }, - }, - }; - - const feeLimitValidationProps = { - onClick: () => - estimatedFeeLimit && - setValue(FEE_LIMIT, estimatedFeeLimit, { - shouldValidate: true, - }), - text: translationString('CUSTOM_FEE_LIMIT_USE_RECOMMENDED'), - }; - const validationButtonProps = - feeLimitError?.type === 'feeLimit' ? feeLimitValidationProps : undefined; - - return ( - - - } - > - - - - - - - - - - {getCurrentFee(feeInfo.levels)} {getFeeUnits(networkType)} - - - - - - - {useFeeLimit ? ( - } - locale={locale} - control={control} - inputState={getInputState(feeLimitError)} - name={FEE_LIMIT} - data-testid={FEE_LIMIT} - bottomText={ - feeLimitError?.message ? ( - - ) : null - } - rules={feeLimitRules} - /> - ) : ( - )} /> - )} - : undefined} - locale={locale} - control={control} - inputState={getInputState(feePerUnitError)} - innerAddon={ - - {feeUnits} - - } - name={FEE_PER_UNIT} - data-testid={FEE_PER_UNIT} - rules={feeRules} - bottomText={feePerUnitError?.message || null} - /> - - {feeDifferenceWarning && {feeDifferenceWarning}} - - ); -}; diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx new file mode 100644 index 00000000000..2c126b6a9fb --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx @@ -0,0 +1,30 @@ +import { NetworkType } from '@suite-common/wallet-config'; +import { getFeeUnits } from '@suite-common/wallet-utils'; +import { Icon, IconName, Row, Text } from '@trezor/components'; +import { spacings } from '@trezor/theme'; + +import { Translation } from 'src/components/suite'; + +type CurrentFeeProps = { + networkType: NetworkType; + feeIconName: IconName; + currentFee: string; + isEip1559?: boolean; +}; + +// For priority fees it should show current base fee +export const CurrentFee = ({ networkType, feeIconName, currentFee }: CurrentFeeProps) => ( + + + + + + + + {currentFee} {getFeeUnits(networkType)} + + + + + +); diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx new file mode 100644 index 00000000000..c62ff7fea79 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx @@ -0,0 +1,122 @@ +import { + Control, + FieldErrors, + UseFormGetValues, + UseFormRegister, + UseFormSetValue, +} from 'react-hook-form'; + +import { NetworkType } from '@suite-common/wallet-config'; +import { FeeInfo, FormState } from '@suite-common/wallet-types'; +import { getFeeUnits, isInteger } from '@suite-common/wallet-utils'; + +import { useSelector, useTranslation } from 'src/hooks/suite'; +import { TranslationFunction } from 'src/hooks/suite/useTranslation'; +import { selectLanguage } from 'src/reducers/suite/suiteReducer'; + +import { CurrentFee } from './CurrentFee'; +import { CustomFeeEthereum } from './CustomFeeEthereum'; +import { CustomFeeMisc } from './CustomFeeMisc'; +import { CustomFeeWrapper } from './CustomFeeWrapper'; + +export const FEE_PER_UNIT = 'feePerUnit'; +export const FEE_LIMIT = 'feeLimit'; + +export type CustomFeeBasicProps = { + networkType: NetworkType; + feeInfo: FeeInfo; + errors: FieldErrors; + register: UseFormRegister; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; + composedFeePerByte: string; + locale: string; + translationString: TranslationFunction; + feeUnits: string; + sharedRules: { + required: string; + validate: (value: string) => string | undefined; + }; +}; + +interface CustomFeeProps { + networkType: NetworkType; + feeInfo: FeeInfo; + errors: FieldErrors; + register: UseFormRegister; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; + composedFeePerByte: string; +} + +export const CustomFee = ({ + networkType, + feeInfo, + register, + control, + composedFeePerByte, + ...props +}: CustomFeeProps) => { + const { translationString } = useTranslation(); + + const sharedRules = { + required: translationString('CUSTOM_FEE_IS_NOT_SET'), + // Allow decimals in ETH since GWEI is not a satoshi. + validate: (value: string) => { + if (['bitcoin', 'ethereum'].includes(networkType) && !isInteger(value)) { + return translationString('CUSTOM_FEE_IS_NOT_INTEGER'); + } + }, + }; + + const locale = useSelector(selectLanguage); + + const getCurrentFee = () => { + const { levels } = feeInfo; + const middleIndex = Math.floor((levels.length - 1) / 2); + + return levels[middleIndex].feePerUnit; + }; + + const feeIconName = networkType === 'ethereum' ? 'gasPump' : 'receipt'; + const feeUnits = getFeeUnits(networkType); + + return ( + + + {networkType === 'ethereum' ? ( + + ) : ( + + )} + + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeEthereum.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeEthereum.tsx new file mode 100644 index 00000000000..bd01851b63b --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeEthereum.tsx @@ -0,0 +1,125 @@ +import { FieldErrors, UseFormReturn } from 'react-hook-form'; + +import { FormState } from '@suite-common/wallet-types'; +import { getInputState } from '@suite-common/wallet-utils'; +import { Text } from '@trezor/components'; +import { NumberInput } from '@trezor/product-components'; +import { BigNumber } from '@trezor/utils/src/bigNumber'; + +import { Translation } from 'src/components/suite'; +import { InputError } from 'src/components/wallet'; +import { validateDecimals } from 'src/utils/suite/validation'; + +import { CustomFeeBasicProps, FEE_LIMIT, FEE_PER_UNIT } from './CustomFee'; + +export const CustomFeeEthereum = ({ + networkType, + feeInfo, + control, + locale, + translationString, + feeUnits, + sharedRules, + ...props +}: CustomFeeBasicProps) => { + // Type assertion allowing to make the component reusable, see https://stackoverflow.com/a/73624072. + const { getValues, setValue } = props as unknown as UseFormReturn; + const errors = props.errors as unknown as FieldErrors; + + const { maxFee, minFee } = feeInfo; + + const estimatedFeeLimit = getValues('estimatedFeeLimit'); + const feePerUnitError = errors.feePerUnit; + const feeLimitError = errors.feeLimit; + + const feeLimitRules = { + required: translationString('GAS_LIMIT_IS_NOT_SET'), + validate: { + ...sharedRules.validate, + feeLimit: (value: string) => { + const feeBig = new BigNumber(value); + if (estimatedFeeLimit && feeBig.lt(estimatedFeeLimit)) { + return translationString('CUSTOM_FEE_LIMIT_BELOW_RECOMMENDED'); + } + }, + }, + }; + const feeRules = { + ...sharedRules, + validate: { + ...sharedRules.validate, + // GWEI: 9 decimal places. + ethereumDecimalsLimit: validateDecimals(translationString, { + decimals: 9, + except: networkType !== 'ethereum', + }), + range: (value: string) => { + const customFee = new BigNumber(value); + + if (customFee.isGreaterThan(maxFee) || customFee.isLessThan(minFee)) { + return translationString('CUSTOM_FEE_NOT_IN_RANGE', { + minFee: new BigNumber(minFee).toString(), + maxFee: new BigNumber(maxFee).toString(), + }); + } + }, + }, + }; + + const feeLimitValidationProps = { + onClick: () => + estimatedFeeLimit && + setValue(FEE_LIMIT, estimatedFeeLimit, { + shouldValidate: true, + }), + text: translationString('CUSTOM_FEE_LIMIT_USE_RECOMMENDED'), + }; + + const feeLimitValidationButtonProps = + feeLimitError?.type === 'feeLimit' ? feeLimitValidationProps : undefined; + + const gasLimitInput = ( + } + locale={locale} + control={control} + inputState={getInputState(feeLimitError)} + name={FEE_LIMIT} + data-testid={FEE_LIMIT} + bottomText={ + feeLimitError?.message ? ( + + ) : null + } + rules={feeLimitRules} + /> + ); + + const legacyEvmInputFields = ( + } + locale={locale} + control={control} + inputState={getInputState(feePerUnitError)} + innerAddon={ + + {feeUnits} + + } + name={FEE_PER_UNIT} + data-testid={FEE_PER_UNIT} + rules={feeRules} + bottomText={feePerUnitError?.message || null} + /> + ); + + return ( + <> + {gasLimitInput} + {legacyEvmInputFields} + + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeMisc.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeMisc.tsx new file mode 100644 index 00000000000..dfaabd0359e --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeMisc.tsx @@ -0,0 +1,100 @@ +import { FieldErrors, FieldPath, UseFormReturn } from 'react-hook-form'; + +import { FormState } from '@suite-common/wallet-types'; +import { getInputState } from '@suite-common/wallet-utils'; +import { Note, Text } from '@trezor/components'; +import { NumberInput } from '@trezor/product-components'; +import { BigNumber } from '@trezor/utils/src/bigNumber'; + +import { Translation } from 'src/components/suite'; +import { validateDecimals } from 'src/utils/suite/validation'; + +import { CustomFeeBasicProps, FEE_LIMIT, FEE_PER_UNIT } from './CustomFee'; + +/* + * Custom fee for misc networks (everything except ethereum) + */ + +export const CustomFeeMisc = ({ + networkType, + feeInfo, + register, + control, + composedFeePerByte, + locale, + translationString, + feeUnits, + sharedRules, + ...props +}: CustomFeeBasicProps) => { + // Type assertion allowing to make the component reusable, see https://stackoverflow.com/a/73624072. + const { getValues } = props as unknown as UseFormReturn; + const errors = props.errors as unknown as FieldErrors; + + const { maxFee, minFee } = feeInfo; + + const feePerUnitValue = getValues(FEE_PER_UNIT); + const feePerUnitError = errors.feePerUnit; + + const isComposedFeeRateDifferent = + !feePerUnitError && composedFeePerByte && feePerUnitValue !== composedFeePerByte; + + let feeDifferenceWarning; + if (isComposedFeeRateDifferent && networkType === 'bitcoin') { + const baseFee = getValues('baseFee'); + feeDifferenceWarning = ( + + {composedFeePerByte} {feeUnits} + + ), + }} + /> + ); + } + + const feeRules = { + ...sharedRules, + validate: { + ...sharedRules.validate, + bitcoinDecimalsLimit: validateDecimals(translationString, { + decimals: 2, + except: networkType !== 'bitcoin', + }), + range: (value: string) => { + const customFee = new BigNumber(value); + + if (customFee.isGreaterThan(maxFee) || customFee.isLessThan(minFee)) { + return translationString('CUSTOM_FEE_NOT_IN_RANGE', { + minFee: new BigNumber(minFee).toString(), + maxFee: new BigNumber(maxFee).toString(), + }); + } + }, + }, + }; + + return ( + <> + )} /> + + {feeUnits} + + } + name={FEE_PER_UNIT} + data-testid={FEE_PER_UNIT} + rules={feeRules} + bottomText={feePerUnitError?.message || null} + /> + {feeDifferenceWarning && {feeDifferenceWarning}} + + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeWrapper.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeWrapper.tsx new file mode 100644 index 00000000000..03686713e16 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeWrapper.tsx @@ -0,0 +1,29 @@ +import { Banner, Column } from '@trezor/components'; +import { spacings } from '@trezor/theme'; +import { HELP_CENTER_TRANSACTION_FEES_URL } from '@trezor/urls'; + +import { Translation } from 'src/components/suite'; +import { LearnMoreButton } from 'src/components/suite/LearnMoreButton'; + +interface CustomFeeWrapperProps { + children: React.ReactNode; +} + +export const CustomFeeWrapper = ({ children }: CustomFeeWrapperProps) => ( + + + } + > + + + {children} + +); diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/index.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/index.tsx new file mode 100644 index 00000000000..ad11835b799 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/index.tsx @@ -0,0 +1,7 @@ +import { CurrentFee } from './CurrentFee'; +import { CustomFee } from './CustomFee'; +import { CustomFeeEthereum } from './CustomFeeEthereum'; +import { CustomFeeMisc } from './CustomFeeMisc'; +import { CustomFeeWrapper } from './CustomFeeWrapper'; + +export { CustomFee, CurrentFee, CustomFeeEthereum, CustomFeeMisc, CustomFeeWrapper }; diff --git a/packages/suite/src/components/wallet/Fees/FeeDetails.tsx b/packages/suite/src/components/wallet/Fees/FeeDetails.tsx deleted file mode 100644 index 475deb0c38c..00000000000 --- a/packages/suite/src/components/wallet/Fees/FeeDetails.tsx +++ /dev/null @@ -1,298 +0,0 @@ -import React, { useEffect, useRef } from 'react'; - -import styled from 'styled-components'; - -import { formatDurationStrict } from '@suite-common/suite-utils'; -import { NetworkSymbol, NetworkType, getNetwork } from '@suite-common/wallet-config'; -import { - FeeInfo, - PrecomposedTransaction, - PrecomposedTransactionCardano, -} from '@suite-common/wallet-types'; -import { getFeeUnits } from '@suite-common/wallet-utils'; -import { Box, Column, ElevationUp, RadioCard, Row, Text } from '@trezor/components'; -import { FeeLevel } from '@trezor/connect'; -import { FeeRate } from '@trezor/product-components'; -import { spacings, spacingsPx } from '@trezor/theme'; - -import { FiatValue } from 'src/components/suite/FiatValue'; -import { useLocales } from 'src/hooks/suite'; - -import { FeeOption } from './Fees'; - -type DetailsProps = { - networkType: NetworkType; - symbol: NetworkSymbol; - selectedLevel: FeeLevel; - // fields below are validated as false-positives, eslint claims that they are not used... - feeOptions: FeeOption[]; - feeInfo: FeeInfo; - changeFeeLevel: (level: FeeLevel['label']) => void; - transactionInfo?: PrecomposedTransaction | PrecomposedTransactionCardano; - - showFee: boolean; -}; - -export const FeeCardsWrapper = styled.div<{ $columns: number }>` - width: 100%; - display: grid; - grid-template-columns: repeat(${({ $columns }) => $columns}, 1fr); - gap: ${spacingsPx.sm}; - align-items: stretch; -`; - -type FeeCardProps = { - value: FeeLevel['label']; - isSelected: boolean; - changeFeeLevel: (level: FeeLevel['label']) => void; - topLeftChild: React.ReactNode; - topRightChild: React.ReactNode; - bottomLeftChild: React.ReactNode; - bottomRightChild: React.ReactNode; -}; - -const FEE_CARD_MIN_WIDTH = 220; - -type ResizeObserverType = ( - feeOptions: FeeOption[], - setColumns: (columns: number) => void, -) => ResizeObserver; - -const resizeObserver: ResizeObserverType = (feeOptions, setColumns) => - new ResizeObserver(entries => { - const borderBoxSize = entries[0].borderBoxSize?.[0]; - if (!borderBoxSize) { - return; - } - - const { inlineSize: elementWidth } = borderBoxSize; - - const minWidth = (FEE_CARD_MIN_WIDTH + spacings.xs) * feeOptions.length; - - const columns = elementWidth > minWidth ? feeOptions.length : 1; - - setColumns(columns); - }); - -const FeeCard = ({ - value, - isSelected, - changeFeeLevel, - topLeftChild, - topRightChild, - bottomLeftChild, - bottomRightChild, -}: FeeCardProps) => ( - - - changeFeeLevel(value)} isActive={isSelected}> - - - {topLeftChild} - - {topRightChild} - - - - {bottomLeftChild} - - {bottomRightChild} - - - - - - -); - -const BitcoinDetails = ({ - networkType, - feeInfo, - transactionInfo, - feeOptions, - showFee, - selectedLevel, - changeFeeLevel, - symbol, -}: DetailsProps) => { - const [columns, setColumns] = React.useState(1); - const locale = useLocales(); - - const hasInfo = transactionInfo && transactionInfo.type !== 'error'; - const ref = useRef(null); - - useEffect(() => { - if (!ref.current) return; - - resizeObserver(feeOptions, setColumns).observe(ref.current); - - return () => resizeObserver(feeOptions, setColumns).disconnect(); - }, [feeOptions, setColumns]); - - return ( - showFee && ( - - {feeOptions?.map((fee, index) => ( - {fee.label} - } - topRightChild={ - <> - ~ - {formatDurationStrict( - feeInfo.blockTime * (fee?.blocks ?? 0) * 60, - locale, - )} - - } - bottomLeftChild={ - - } - bottomRightChild={ - <> - - {hasInfo ? ` (${transactionInfo.bytes} B)` : ''} - - } - /> - ))} - - ) - ); -}; - -const EthereumDetails = ({ - showFee, - feeOptions, - selectedLevel, - changeFeeLevel, - symbol, - networkType, -}: DetailsProps) => { - const hasSettlementLayer = !!getNetwork(symbol).settlementLayer; - - // TODO: this can be probably moved to FeeRate component - const formatFeePerUnit = (feePerUnit?: string) => { - const num = Number(feePerUnit) || 0; - - const numOfDecimalPlaces = hasSettlementLayer ? 4 : 2; - - const multiplier = Math.pow(10, numOfDecimalPlaces); - - return (Math.ceil(num * multiplier) / multiplier).toFixed(numOfDecimalPlaces); - }; - - return ( - showFee && ( - - - {feeOptions?.map((fee, index) => ( - {fee.label} - } - topRightChild="" - bottomLeftChild={ - - } - bottomRightChild={ - - } - /> - ))} - - - ) - ); -}; - -// Solana, Ripple, Cardano and other networks with only one option -const MiscDetails = ({ - networkType, - showFee, - feeOptions, - symbol, - changeFeeLevel, -}: DetailsProps) => { - if (!feeOptions?.length) return null; - - const feeOption = feeOptions[0]; // in the future Solana should have it's own Details component - const feeAmount = networkType === 'solana' ? feeOption.feePerTx : feeOption.feePerUnit; - - return ( - showFee && ( - - {feeOption.label} - } - topRightChild="" - bottomLeftChild={ - - } - bottomRightChild={ - - {feeAmount} {getFeeUnits(networkType)} - - } - /> - - ) - ); -}; - -export const FeeDetails = (props: DetailsProps) => { - const { networkType } = props; - - const detailsComponentMap: Partial>> = { - bitcoin: BitcoinDetails, - ethereum: EthereumDetails, - }; - - const DetailsComponent = detailsComponentMap[networkType] ?? MiscDetails; - - return ( - - - - - - ); -}; diff --git a/packages/suite/src/components/wallet/Fees/Fees.tsx b/packages/suite/src/components/wallet/Fees/Fees.tsx index d3a28377b5b..fb2befdb53a 100644 --- a/packages/suite/src/components/wallet/Fees/Fees.tsx +++ b/packages/suite/src/components/wallet/Fees/Fees.tsx @@ -27,7 +27,7 @@ import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/su import { Account } from 'src/types/wallet'; import { CustomFee } from './CustomFee'; -import { FeeDetails } from './FeeDetails'; +import { StandardFee } from './StandardFee'; const FEE_LEVELS_TRANSLATIONS: Record = { custom: 'FEE_LEVEL_ADVANCED', @@ -43,7 +43,7 @@ export type FeeOption = { blocks?: number; feePerUnit?: string; networkAmount?: string | null; - feePerTx?: string; + feePerTx?: string; // Solana specific }; const SelectBarWrapper = styled.div` @@ -71,7 +71,7 @@ const buildFeeOptions = ( networkType: NetworkType, symbol: NetworkSymbol, composedLevels?: PrecomposedLevels | PrecomposedLevelsCardano, -) => { +): FeeOption[] => { const filteredLevels = levels.filter(level => level.label !== 'custom'); const getNetworkAmount = (level: FeeLevel) => { @@ -145,7 +145,9 @@ export const Fees = ({ const errors = props.errors as unknown as FieldErrors; const error = errors.selectedFee; - const selectedLevel = feeInfo.levels.find(level => level.label === selectedOption)!; + const selectedLevel = + feeInfo.levels.find(level => level.label === selectedOption) || + feeInfo.levels.find(level => level.label === 'normal')!; const transactionInfo = composedLevels?.[selectedOption]; const feeOptions = buildFeeOptions(feeInfo.levels, networkType, symbol, composedLevels); @@ -197,7 +199,10 @@ export const Fees = ({ orientation="horizontal" selectedOption={isCustomFee ? 'custom' : 'normal'} options={[ - { label: , value: 'normal' }, + { + label: , + value: 'normal', + }, { label: , value: 'custom' }, ]} onChange={() => changeFeeLevel(isCustomFee ? 'normal' : 'custom')} @@ -208,7 +213,7 @@ export const Fees = ({ {!isCustomFee && ( - { + const locale = useLocales(); + + const hasInfo = transactionInfo && transactionInfo.type !== 'error'; + + return ( + showFee && ( + <> + {feeOptions?.map((fee, index) => ( + {fee.label} + } + topRightChild={ + <> + ~ + {formatDurationStrict( + feeInfo.blockTime * (fee?.blocks ?? 0) * 60, + locale, + )} + + } + bottomLeftChild={ + + } + bottomRightChild={ + <> + + {hasInfo ? ` (${transactionInfo.bytes} B)` : ''} + + } + /> + ))} + + ) + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx new file mode 100644 index 00000000000..118161f4078 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx @@ -0,0 +1,44 @@ +import { FeeRate } from '@trezor/product-components'; + +import { FiatValue } from 'src/components/suite'; + +import { FeeCard } from './FeeCard'; +import { StandardFeeProps } from './StandardFee'; + +export const EthereumFeeCards = ({ + showFee, + feeOptions, + selectedLevel, + changeFeeLevel, + symbol, + networkType, +}: StandardFeeProps) => + showFee && ( + <> + {feeOptions?.map((fee, index) => ( + {fee.label}} + topRightChild="" + bottomLeftChild={ + + } + bottomRightChild={ + + } + /> + ))} + + ); diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/FeeCard.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/FeeCard.tsx new file mode 100644 index 00000000000..e98da241c6d --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/FeeCard.tsx @@ -0,0 +1,45 @@ +import { Box, Column, ElevationUp, RadioCard, Row, Text } from '@trezor/components'; +import { FeeLevel } from '@trezor/connect'; + +type FeeCardProps = { + value: FeeLevel['label']; + isSelected: boolean; + changeFeeLevel: (level: FeeLevel['label']) => void; + topLeftChild: React.ReactNode; + topRightChild: React.ReactNode; + bottomLeftChild: React.ReactNode; + bottomRightChild: React.ReactNode; +}; + +export const FEE_CARD_MIN_WIDTH = 220; + +export const FeeCard = ({ + value, + isSelected, + changeFeeLevel, + topLeftChild, + topRightChild, + bottomLeftChild, + bottomRightChild, +}: FeeCardProps) => ( + + + changeFeeLevel(value)} isActive={isSelected}> + + + {topLeftChild} + + {topRightChild} + + + + {bottomLeftChild} + + {bottomRightChild} + + + + + + +); diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/MiscFeeCards.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/MiscFeeCards.tsx new file mode 100644 index 00000000000..dada0708e25 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/MiscFeeCards.tsx @@ -0,0 +1,48 @@ +import { getFeeUnits } from '@suite-common/wallet-utils'; +import { Text } from '@trezor/components'; + +import { FiatValue } from 'src/components/suite'; + +import { FeeCard } from './FeeCard'; +import { StandardFeeProps } from './StandardFee'; + +// Solana, Ripple, Cardano and other networks with only one option +export const MiscFeeCards = ({ + networkType, + showFee, + feeOptions, + symbol, + changeFeeLevel, +}: StandardFeeProps) => { + if (!feeOptions?.length) return null; + + const feeOption = feeOptions[0]; // in the future Solana should have it's own Details component + const feeAmount = networkType === 'solana' ? feeOption.feePerTx : feeOption.feePerUnit; + + return ( + showFee && ( + {feeOption.label} + } + topRightChild="" + bottomLeftChild={ + + } + bottomRightChild={ + + {feeAmount} {getFeeUnits(networkType)} + + } + /> + ) + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/StandardFee.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/StandardFee.tsx new file mode 100644 index 00000000000..d73b671eef8 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/StandardFee.tsx @@ -0,0 +1,94 @@ +import { useEffect, useRef, useState } from 'react'; + +import styled from 'styled-components'; + +import { NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; +import { + FeeInfo, + PrecomposedTransaction, + PrecomposedTransactionCardano, +} from '@suite-common/wallet-types'; +import { Row, Text } from '@trezor/components'; +import { FeeLevel } from '@trezor/connect'; +import { spacings, spacingsPx } from '@trezor/theme'; + +import { BitcoinFeeCards } from './BitcoinFeeCards'; +import { EthereumFeeCards } from './EthereumFeeCards'; +import { MiscFeeCards } from './MiscFeeCards'; +import { FeeOption } from '../Fees'; +import { FEE_CARD_MIN_WIDTH } from './FeeCard'; + +export type StandardFeeProps = { + networkType: NetworkType; + symbol: NetworkSymbol; + selectedLevel: FeeLevel; + // fields below are validated as false-positives, eslint claims that they are not used... + feeOptions: FeeOption[]; + feeInfo: FeeInfo; + changeFeeLevel: (level: FeeLevel['label']) => void; + transactionInfo?: PrecomposedTransaction | PrecomposedTransactionCardano; + + showFee: boolean; +}; + +export const FeeCardsWrapper = styled.div<{ $columns: number }>` + width: 100%; + display: grid; + grid-template-columns: repeat(${({ $columns }) => $columns}, 1fr); + gap: ${spacingsPx.sm}; + align-items: stretch; +`; + +type ResizeObserverType = ( + feeOptions: FeeOption[], + setColumns: (columns: number) => void, +) => ResizeObserver; + +export const resizeObserver: ResizeObserverType = (feeOptions, setColumns) => + new ResizeObserver(entries => { + const borderBoxSize = entries[0].borderBoxSize?.[0]; + if (!borderBoxSize) { + return; + } + + const { inlineSize: elementWidth } = borderBoxSize; + + const minWidth = (FEE_CARD_MIN_WIDTH + spacings.xs) * feeOptions.length; + + const columns = elementWidth > minWidth ? feeOptions.length : 1; + + setColumns(columns); + }); + +export const StandardFee = (props: StandardFeeProps) => { + const { networkType, feeOptions } = props; + + const [columns, setColumns] = useState(1); + + const ref = useRef(null); + + useEffect(() => { + if (!ref.current) return; + + resizeObserver(feeOptions, setColumns).observe(ref.current); + + return () => resizeObserver(feeOptions, setColumns).disconnect(); + }, [feeOptions, setColumns]); + + const feeCardsComponentMap: Partial>> = { + bitcoin: BitcoinFeeCards, + ethereum: EthereumFeeCards, + }; + + const FeeCardsComponent = feeCardsComponentMap[networkType] ?? MiscFeeCards; + + return ( + + + + + + + + ); +}; diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/index.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/index.tsx new file mode 100644 index 00000000000..0819accc6eb --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/index.tsx @@ -0,0 +1,7 @@ +import { BitcoinFeeCards } from './BitcoinFeeCards'; +import { EthereumFeeCards } from './EthereumFeeCards'; +import { FeeCard } from './FeeCard'; +import { MiscFeeCards } from './MiscFeeCards'; +import { StandardFee } from './StandardFee'; + +export { StandardFee, EthereumFeeCards, MiscFeeCards, BitcoinFeeCards, FeeCard }; diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index fbdfe1af120..6116d280951 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -3418,6 +3418,10 @@ export default defineMessages({ id: 'TR_CURRENT_FEE_CUSTOM_FEES', defaultMessage: 'Current network fee:', }, + GAS_LIMIT_IS_NOT_SET: { + id: 'GAS_LIMIT_IS_NOT_SET', + defaultMessage: 'Set gas limit for this transaction', + }, TR_GAS_LIMIT: { id: 'TR_GAS_LIMIT', defaultMessage: 'Gas limit', diff --git a/suite-common/wallet-config/src/utils.ts b/suite-common/wallet-config/src/utils.ts index fd3d20270c5..edb26a65b15 100644 --- a/suite-common/wallet-config/src/utils.ts +++ b/suite-common/wallet-config/src/utils.ts @@ -76,6 +76,17 @@ export const isNetworkSymbol = (symbol: NetworkSymbolExtended): symbol is Networ */ export const getNetwork = (symbol: NetworkSymbol): Network => networks[symbol]; +/** + * Check wether the network has a settlement layer. Used to check Ethereum L2s. + * @param symbol + * @returns boolean + */ +export const hasNetworkSettlementLayer = (symbol: NetworkSymbol) => { + const hasSettlementLayer = !!getNetwork(symbol).settlementLayer; + + return hasSettlementLayer; +}; + /** * Use instead of getNetwork, if there is not a guarantee that the symbol is a valid network symbol. * @param symbol