diff --git a/packages/product-components/src/components/FeeRate/FeeRate.tsx b/packages/product-components/src/components/FeeRate/FeeRate.tsx index 831077754c3..f4456368cd9 100644 --- a/packages/product-components/src/components/FeeRate/FeeRate.tsx +++ b/packages/product-components/src/components/FeeRate/FeeRate.tsx @@ -1,21 +1,38 @@ -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; + const fee = (() => { + switch (networkType) { + case 'ethereum': { + const decimals = hasNetworkSettlementLayer(symbol) ? 4 : 2; + const multiplier = Math.pow(10, decimals); + const value = Math.ceil(Number(feeRate) * multiplier) / multiplier; + + return value.toFixed(decimals); + } + case 'bitcoin': { + const feeBn = typeof feeRate === 'string' ? new BigNumber(feeRate) : feeRate; + + return feeBn.toFixed(2); + } + default: + return typeof feeRate === 'string' ? feeRate : feeRate.toString(); + } + })(); return ( - {networkType === 'bitcoin' ? fee.toFixed(2) : fee.toString()}  - {getFeeUnits(networkType)} + {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..9013f7de8eb --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx @@ -0,0 +1,29 @@ +import { NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; +import { Icon, IconName, Row, Text } from '@trezor/components'; +import { FeeRate } from '@trezor/product-components'; +import { spacings } from '@trezor/theme'; + +import { Translation } from 'src/components/suite'; + +type CurrentFeeProps = { + networkType: NetworkType; + feeIconName: IconName; + currentFee: string; + symbol: NetworkSymbol; +}; + +export const CurrentFee = ({ networkType, feeIconName, currentFee, symbol }: CurrentFeeProps) => ( + + + + + + + + + + + + + +); 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..3d00bfa4dd6 --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx @@ -0,0 +1,125 @@ +import { + Control, + FieldErrors, + UseFormGetValues, + UseFormRegister, + UseFormSetValue, +} from 'react-hook-form'; + +import { NetworkSymbol, 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; + symbol: NetworkSymbol; + feeInfo: FeeInfo; + errors: FieldErrors; + register: UseFormRegister; + control: Control; + setValue: UseFormSetValue; + getValues: UseFormGetValues; + composedFeePerByte: string; +} + +export const CustomFee = ({ + networkType, + symbol, + 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..0199f216f7f --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFeeMisc.tsx @@ -0,0 +1,96 @@ +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'; + +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/FeeDetails.tsx b/packages/suite/src/components/wallet/Fees/FeeDetails.tsx deleted file mode 100644 index eded9a44fe2..00000000000 --- a/packages/suite/src/components/wallet/Fees/FeeDetails.tsx +++ /dev/null @@ -1,291 +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..ff77f841387 100644 --- a/packages/suite/src/components/wallet/Fees/Fees.tsx +++ b/packages/suite/src/components/wallet/Fees/Fees.tsx @@ -26,8 +26,8 @@ import { spacings, spacingsPx } from '@trezor/theme'; import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/suite'; import { Account } from 'src/types/wallet'; -import { CustomFee } from './CustomFee'; -import { FeeDetails } from './FeeDetails'; +import { CustomFee } from './CustomFee/CustomFee'; +import { StandardFee } from './StandardFee/StandardFee'; const FEE_LEVELS_TRANSLATIONS: Record = { custom: 'FEE_LEVEL_ADVANCED', @@ -37,13 +37,13 @@ const FEE_LEVELS_TRANSLATIONS: Record = { low: 'FEE_LEVEL_LOW', } as const; -export type FeeOption = { +export type FeeOptionType = { label: React.ReactNode; value: FeeLevel['label']; 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, -) => { +): FeeOptionType[] => { const filteredLevels = levels.filter(level => level.label !== 'custom'); const getNetworkAmount = (level: FeeLevel) => { @@ -145,7 +145,7 @@ 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); const transactionInfo = composedLevels?.[selectedOption]; const feeOptions = buildFeeOptions(feeInfo.levels, networkType, symbol, composedLevels); @@ -197,7 +197,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')} @@ -207,8 +210,8 @@ export const Fees = ({ )} - {!isCustomFee && ( - ({ {isCustomFee && ( <> { + const locale = useLocales(); + + if (!showFee || !feeOptions.length) { + return null; + } + + const hasInfo = transactionInfo && transactionInfo.type !== 'error'; + + return 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..2c04ad0d0fe --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx @@ -0,0 +1,41 @@ +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) => { + if (!showFee || !feeOptions.length) { + return null; + } + + return 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..dbe6d2e21db --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/MiscFeeCards.tsx @@ -0,0 +1,51 @@ +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 || !showFee) return null; + + const isSolanaNetwork = networkType === 'solana'; + + const feeOption = feeOptions[0]; + const shouldShowCurrentFee = !isSolanaNetwork || feeOption.networkAmount; + const feeAmount = isSolanaNetwork ? feeOption.feePerTx : feeOption.feePerUnit; + + return ( + {feeOption.label} + } + topRightChild="" + bottomLeftChild={ + + } + bottomRightChild={ + shouldShowCurrentFee && ( + + {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..f7a0f671a3d --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/StandardFee/StandardFee.tsx @@ -0,0 +1,93 @@ +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 } from '@trezor/components'; +import { FeeLevel } from '@trezor/connect'; +import { spacings, spacingsPx } from '@trezor/theme'; + +import { BitcoinFeeCards } from './BitcoinFeeCards'; +import { EthereumFeeCards } from './EthereumFeeCards'; +import { FEE_CARD_MIN_WIDTH } from './FeeCard'; +import { MiscFeeCards } from './MiscFeeCards'; +import { FeeOptionType } from '../Fees'; + +export type StandardFeeProps = { + networkType: NetworkType; + symbol: NetworkSymbol; + selectedLevel: FeeLevel; + // fields below are validated as false-positives, eslint claims that they are not used... + feeOptions: FeeOptionType[]; + 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: FeeOptionType[], + 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; + + const observer = resizeObserver(feeOptions, setColumns); + observer.observe(ref.current); + + return () => observer.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/index.tsx b/packages/suite/src/components/wallet/Fees/index.tsx new file mode 100644 index 00000000000..6ccf66e50ca --- /dev/null +++ b/packages/suite/src/components/wallet/Fees/index.tsx @@ -0,0 +1,23 @@ +import { CurrentFee } from './CustomFee/CurrentFee'; +import { CustomFee } from './CustomFee/CustomFee'; +import { CustomFeeEthereum } from './CustomFee/CustomFeeEthereum'; +import { CustomFeeMisc } from './CustomFee/CustomFeeMisc'; +import { CustomFeeWrapper } from './CustomFee/CustomFeeWrapper'; +import { BitcoinFeeCards } from './StandardFee/BitcoinFeeCards'; +import { EthereumFeeCards } from './StandardFee/EthereumFeeCards'; +import { FeeCard } from './StandardFee/FeeCard'; +import { MiscFeeCards } from './StandardFee/MiscFeeCards'; +import { StandardFee } from './StandardFee/StandardFee'; + +export { + CustomFee, + CurrentFee, + CustomFeeEthereum, + CustomFeeMisc, + CustomFeeWrapper, + BitcoinFeeCards, + EthereumFeeCards, + FeeCard, + MiscFeeCards, + StandardFee, +}; 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..e5f40421892 100644 --- a/suite-common/wallet-config/src/utils.ts +++ b/suite-common/wallet-config/src/utils.ts @@ -76,6 +76,14 @@ 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) => + !!getNetwork(symbol).settlementLayer; + /** * Use instead of getNetwork, if there is not a guarantee that the symbol is a valid network symbol. * @param symbol