From 8b615abf6e2a3345fce70438803ef99a5401a05d Mon Sep 17 00:00:00 2001 From: Albina Nikiforova Date: Tue, 21 Jan 2025 20:05:14 +0100 Subject: [PATCH] feat(suite): WIP frontend --- packages/connect/src/api/bitcoin/Fees.ts | 7 +- .../src/components/wallet/Fees/CustomFee.tsx | 84 ++++-- .../src/components/wallet/Fees/FeeDetails.tsx | 216 +++++++++++--- .../suite/src/components/wallet/Fees/Fees.tsx | 277 ++++++++++-------- .../src/hooks/wallet/useSendFormCompose.ts | 15 +- .../suite/src/views/wallet/send/SendFees.tsx | 2 + .../src/send/sendFormEthereumThunks.ts | 5 +- .../wallet-core/src/send/sendFormThunks.ts | 5 +- .../wallet-core/src/send/sendFormTypes.ts | 1 + suite-common/wallet-types/src/sendForm.ts | 13 + 10 files changed, 448 insertions(+), 177 deletions(-) diff --git a/packages/connect/src/api/bitcoin/Fees.ts b/packages/connect/src/api/bitcoin/Fees.ts index d427170a5395..76bac5b2aa1a 100644 --- a/packages/connect/src/api/bitcoin/Fees.ts +++ b/packages/connect/src/api/bitcoin/Fees.ts @@ -86,7 +86,7 @@ export class FeeLevels { if (response.eip1559) { const eip1559LevelKeys = ['low', 'medium', 'high'] as const; - const { eip1559, ...baseLevel } = response; + const { eip1559 } = response; const eip1559Levels = eip1559LevelKeys.map(levelKey => { const level = eip1559[levelKey]; @@ -104,10 +104,7 @@ export class FeeLevels { }; }); - this.levels = [ - { ...baseLevel, label: 'normal' as const, blocks: -1, ethFeeType: 'legacy' }, - ...eip1559Levels, - ]; + this.levels = [...eip1559Levels]; } else { this.levels[0] = { ...this.levels[0], diff --git a/packages/suite/src/components/wallet/Fees/CustomFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee.tsx index 585c3c5b66c2..84ed623cebc0 100644 --- a/packages/suite/src/components/wallet/Fees/CustomFee.tsx +++ b/packages/suite/src/components/wallet/Fees/CustomFee.tsx @@ -9,7 +9,7 @@ import { } from 'react-hook-form'; import { BigNumber } from '@trezor/utils/src/bigNumber'; -import { Note, Banner, variables, Grid, Column, useMediaQuery, Text } from '@trezor/components'; +import { Note, Banner, Grid, Column, Text } from '@trezor/components'; import { getInputState, getFeeUnits, isInteger } from '@suite-common/wallet-utils'; import { FeeInfo, FormState } from '@suite-common/wallet-types'; import { NetworkType } from '@suite-common/wallet-config'; @@ -28,6 +28,9 @@ import { InputError } from '../InputError'; const FEE_PER_UNIT = 'feePerUnit'; const FEE_LIMIT = 'feeLimit'; +const MAX_FEE_PER_GAS = 'maxFeePerGas'; +const MAX_PRIORITY_FEE_PER_GAS = 'maxPriorityFeePerGas'; + interface CustomFeeProps { networkType: NetworkType; feeInfo: FeeInfo; @@ -38,6 +41,7 @@ interface CustomFeeProps { getValues: UseFormGetValues; changeFeeLimit?: (value: string) => void; composedFeePerByte: string; + shouldUsePriorityFees: boolean; } export const CustomFee = ({ @@ -47,10 +51,11 @@ export const CustomFee = ({ control, changeFeeLimit, composedFeePerByte, + shouldUsePriorityFees = false, ...props }: CustomFeeProps) => { const { translationString } = useTranslation(); - const isBelowLaptop = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.LG})`); + // const isBelowLaptop = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.LG})`); const locale = useSelector(selectLanguage); @@ -64,6 +69,13 @@ export const CustomFee = ({ const feeUnits = getFeeUnits(networkType); const estimatedFeeLimit = getValues('estimatedFeeLimit'); + const maxFeePerGas = shouldUsePriorityFees ? getValues(MAX_FEE_PER_GAS) : undefined; //only for priority fees on evms + const maxPriorityFeePerGas = shouldUsePriorityFees + ? getValues(MAX_PRIORITY_FEE_PER_GAS) + : undefined; + + console.log('customFees', shouldUsePriorityFees, maxFeePerGas, maxPriorityFeePerGas); + const feePerUnitError = errors.feePerUnit; const feeLimitError = errors.feeLimit; @@ -160,7 +172,7 @@ export const CustomFee = ({ > - + {useFeeLimit ? ( } @@ -183,21 +195,57 @@ export const CustomFee = ({ ) : ( )} /> )} - : 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} - /> + + {shouldUsePriorityFees ? ( + <> + } + locale={locale} + control={control} + inputState={getInputState(feePerUnitError)} + innerAddon={ + + {feeUnits} + + } + name={MAX_PRIORITY_FEE_PER_GAS} + data-testid={MAX_PRIORITY_FEE_PER_GAS} + rules={feeRules} + bottomText={feePerUnitError?.message || null} + /> + } + locale={locale} + control={control} + inputState={getInputState(feePerUnitError)} + innerAddon={ + + {feeUnits} + + } + name={MAX_FEE_PER_GAS} + data-testid={MAX_FEE_PER_GAS} + rules={feeRules} + bottomText={feePerUnitError?.message || null} + /> + + ) : ( + : 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/FeeDetails.tsx b/packages/suite/src/components/wallet/Fees/FeeDetails.tsx index 0482a3645f46..03637d02ae27 100644 --- a/packages/suite/src/components/wallet/Fees/FeeDetails.tsx +++ b/packages/suite/src/components/wallet/Fees/FeeDetails.tsx @@ -1,11 +1,13 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; + +import { useTheme } from 'styled-components'; import { spacings } from '@trezor/theme'; -import { Row, Text } from '@trezor/components'; +import { Card, Column, Grid, Row, Text, useMediaQuery, variables } from '@trezor/components'; import { FeeLevel } from '@trezor/connect'; -import { getFeeUnits } from '@suite-common/wallet-utils'; +import { formatNetworkAmount, getFeeUnits } from '@suite-common/wallet-utils'; import { formatDuration } from '@suite-common/suite-utils'; -import { NetworkType } from '@suite-common/wallet-config'; +import { NetworkType, NetworkSymbol } from '@suite-common/wallet-config'; import { PrecomposedTransaction, PrecomposedTransactionCardano, @@ -13,14 +15,22 @@ import { } from '@suite-common/wallet-types'; import { Translation } from 'src/components/suite/Translation'; +import { FiatValue } from 'src/components/suite/FiatValue'; + +import { FeeOption } from './Fees'; type DetailsProps = { networkType: NetworkType; + symbol: NetworkSymbol; selectedLevel: FeeLevel; + selectedPriorityLevel?: 'high' | 'medium' | 'low'; // fields below are validated as false-positives, eslint claims that they are not used... - + feeOptions: FeeOption[]; feeInfo: FeeInfo; - + selectedLevelOption: string; + setSelectedLevelOption: (option: string) => void; + shouldUsePriorityFees: boolean; + changeFeeLevel: (level: FeeLevel['label']) => void; transactionInfo?: PrecomposedTransaction | PrecomposedTransactionCardano; showFee: boolean; @@ -38,28 +48,100 @@ const Item = ({ label, children }: ItemProps) => ( ); +type FeeCardProps = { + value: FeeLevel['label']; + setSelectedLevelOption: (option: string) => void; + selected: boolean; + children: React.ReactNode; + changeFeeLevel: (level: FeeLevel['label']) => void; +}; + +const FeeCard = ({ + value, + setSelectedLevelOption, + selected, + children, + changeFeeLevel, +}: FeeCardProps) => { + const theme = useTheme(); + + return ( + { + setSelectedLevelOption(value); + changeFeeLevel(value); + }} + minWidth={160} + width="100%" + paddingType="small" + flex="1" + border={selected ? `2px solid ${theme.borderSecondary} !important` : 'none'} + fillType="none" + > + {children} + + ); +}; + const BitcoinDetails = ({ networkType, feeInfo, selectedLevel, transactionInfo, + feeOptions, showFee, + setSelectedLevelOption, + selectedLevelOption, + changeFeeLevel, + symbol, }: DetailsProps) => { const hasInfo = transactionInfo && transactionInfo.type !== 'error'; + const isBelowTablet = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.MD})`); + console.log('selectedLevel', selectedLevel); return ( showFee && ( - <> - }> - {formatDuration(feeInfo.blockTime * selectedLevel.blocks * 60)} - - - }> - {hasInfo ? transactionInfo.feePerByte : selectedLevel.feePerUnit}{' '} - {getFeeUnits(networkType)} - {hasInfo ? ` (${transactionInfo.bytes} B)` : ''} - - + + + {feeOptions && + feeOptions.map((fee, index) => ( + + + {fee.label} + + ~ + {formatDuration( + feeInfo.blockTime * (fee?.blocks || 0) * 60, + )} + + + + + + + + {fee?.feePerUnit} {getFeeUnits(networkType)} + {hasInfo ? ` (${transactionInfo.bytes} B)` : ''} + + + + ))} + + ) ); }; @@ -69,6 +151,10 @@ const EthereumDetails = ({ selectedLevel, transactionInfo, showFee, + feeOptions, + setSelectedLevelOption, + selectedLevelOption, + changeFeeLevel, }: DetailsProps) => { // States to remember the last known values of feeLimit and feePerByte when isComposedTx was true. const [lastKnownFeeLimit, setLastKnownFeeLimit] = useState(''); @@ -81,40 +167,102 @@ const EthereumDetails = ({ setLastKnownFeeLimit(transactionInfo.feeLimit); setLastKnownFeePerByte(transactionInfo.feePerByte); } - }, [isComposedTx, transactionInfo]); + if (!feeOptions[0].eip1559) { + setSelectedLevelOption(feeOptions[0].value); + } + }, [isComposedTx, transactionInfo, feeOptions, setSelectedLevelOption]); + + console.log('transactionInfo', transactionInfo, lastKnownFeeLimit, lastKnownFeePerByte); const gasLimit = isComposedTx - ? transactionInfo.feeLimit - : lastKnownFeeLimit || selectedLevel.feeLimit; + ? transactionInfo.feeLimit || 0 + : lastKnownFeeLimit || selectedLevel?.feeLimit || 0; const gasPrice = isComposedTx - ? transactionInfo.feePerByte - : lastKnownFeePerByte || selectedLevel.feePerUnit; + ? transactionInfo.feePerByte || 0 + : lastKnownFeePerByte || selectedLevel?.feePerUnit || 0; + + const isBelowTablet = useMediaQuery(`(max-width: ${variables.SCREEN_SIZE.MD})`); + + console.log(feeOptions, 'feeOptions'); return ( showFee && ( - <> - }>{gasLimit} - - }> - {gasPrice} {getFeeUnits(networkType)} - - + + + {feeOptions && + feeOptions.map((fee, index) => ( + + {fee.eip1559 ? ( + + + {fee.label} + + {fee.waitTimeEstimate} + + + + {fee.gasFeeFiat} + + {fee.gasFeeCrypto} + + + + ) : ( + {fee.label} + )} + + ))} + + + + }> + {gasPrice} {getFeeUnits(networkType)} + + + + }>{gasLimit} + + + ) ); }; -const RippleDetails = ({ networkType, selectedLevel, showFee }: DetailsProps) => - showFee && {getFeeUnits(networkType)}; +// Solana, Ripple, Cardano and other networks with only one option +const MiscDetails = ({ networkType, showFee, feeOptions }: DetailsProps) => + showFee && ( + {}} + selected={true} + changeFeeLevel={() => {}} + > + + {feeOptions[0].label} + + {feeOptions[0].feePerUnit} {getFeeUnits(networkType)} + + + + ); export const FeeDetails = (props: DetailsProps) => { const { networkType } = props; return ( - - + + {networkType === 'bitcoin' && } {networkType === 'ethereum' && } - {networkType === 'ripple' && } + {networkType !== 'bitcoin' && networkType !== 'ethereum' && ( + + )} ); diff --git a/packages/suite/src/components/wallet/Fees/Fees.tsx b/packages/suite/src/components/wallet/Fees/Fees.tsx index bee3531ee0db..fb00443b9998 100644 --- a/packages/suite/src/components/wallet/Fees/Fees.tsx +++ b/packages/suite/src/components/wallet/Fees/Fees.tsx @@ -6,10 +6,12 @@ import { UseFormReturn, UseFormSetValue, } from 'react-hook-form'; +import { useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import { FeeLevel } from '@trezor/connect'; +import { Network, NetworkType } from '@suite-common/wallet-config'; import { Banner, SelectBar, @@ -19,7 +21,6 @@ import { motionEasing, InfoItem, Row, - Text, } from '@trezor/components'; import { FormState, @@ -29,10 +30,9 @@ import { PrecomposedTransactionFinal, } from '@suite-common/wallet-types'; import { spacings } from '@trezor/theme'; -import { formatNetworkAmount } from '@suite-common/wallet-utils'; import { TranslationKey } from '@suite-common/intl-types'; -import { FiatValue, FormattedCryptoAmount, Translation } from 'src/components/suite'; +import { Translation } from 'src/components/suite'; import { Account } from 'src/types/wallet'; import { CustomFee } from './CustomFee'; @@ -47,13 +47,56 @@ const FEE_LEVELS_TRANSLATIONS: Record = { medium: 'FEE_LEVEL_MEDIUM', } as const; -const buildFeeOptions = (levels: FeeLevel[]) => - levels - .filter(l => l.ethFeeType !== 'eip1559') //TODO: remove this when frontend for eip1559 is implemented - .map(({ label }) => ({ +const buildFeeOptions = (levels: FeeLevel[], networkType: NetworkType) => { + const filteredLevels = levels.filter(level => level.label !== 'custom'); + if (networkType === 'ethereum') { + if (filteredLevels[0].baseFeePerGas) { + return filteredLevels.map(level => ({ + label: , + value: level.label, + waitTimeEstimate: level.maxWaitTimeEstimate, + gasFeeFiat: level.feePerUnit, + gasFeeCrypto: level.feePerUnit, + eip1559: true, + })); + } + + return filteredLevels.map(({ label }) => ({ label: , value: label, })); + } + + if (networkType === 'bitcoin') { + return filteredLevels.map(level => ({ + label: , + value: level.label, + blocks: level.blocks, + feePerUnit: level.feePerUnit, + })); + } + + return filteredLevels.map(level => ({ + label: , + value: level.label, + feePerUnit: level.feePerUnit, + })); +}; + +export type FeeOption = { + label: React.ReactNode; + value: FeeLevel['label']; + blocks?: number; + feePerUnit?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + maxWaitTimeEstimate?: number; + minWaitTimeEstimate?: number; + eip1559?: boolean; + gasFeeFiat?: string; + gasFeeCrypto?: string; + waitTimeEstimate?: string; +}; export interface FeesProps { account: Account; @@ -69,6 +112,7 @@ export interface FeesProps { label?: TranslationKey; rbfForm?: boolean; helperText?: React.ReactNode; + network: Network; } export const Fees = ({ @@ -81,135 +125,134 @@ export const Fees = ({ label, rbfForm, helperText, + network, ...props }: FeesProps) => { // Type assertion allowing to make the component reusable, see https://stackoverflow.com/a/73624072. + const shouldUsePriorityFees = network.features.includes('eip1559'); const { getValues, register, setValue } = props as unknown as UseFormReturn; + const [isCustomFee, setIsCustomFee] = useState(false); + const [selectedLevelOption, setSelectedLevelOption] = useState( + shouldUsePriorityFees ? 'medium' : 'normal', + ); const errors = props.errors as unknown as FieldErrors; - const selectedOption = getValues('selectedFee') || 'normal'; - const isCustomLevel = selectedOption === 'custom'; + const selectedOption = isCustomFee ? 'custom' : selectedLevelOption; + // const isCustomLevel = selectedOption === 'custom'; const error = errors.selectedFee; const selectedLevel = feeInfo.levels.find(level => level.label === selectedOption)!; + console.log('selectedLevel', selectedLevel); const transactionInfo = composedLevels?.[selectedOption]; + console.log('composedLevels', selectedOption, transactionInfo, composedLevels); // Solana has only `normal` fee level, so we do not display any feeOptions since there is nothing to choose from - const feeOptions = networkType === 'solana' ? [] : buildFeeOptions(feeInfo.levels); - - console.log('feeOptions', feeOptions); + const feeOptions = buildFeeOptions(feeInfo.levels, networkType); - const shouldAnimateNormalFee = !isCustomLevel; + // const shouldAnimateNormalFee = !isCustomFee; return ( - - } + + + {networkType === 'ethereum' && ( + } + > + + + } + /> + )} + setIsCustomFee(!isCustomFee)} + isFullWidth={false} + /> + + + <> + + {!isCustomFee && ( + - - - ) : ( - - ) - } - > - {transactionInfo !== undefined && transactionInfo.type !== 'error' && ( - - - - - - - )} - - - {feeOptions.length > 0 && ( - <> - - - {shouldAnimateNormalFee && ( - - - - )} - - - - {isCustomLevel && ( - - - - )} - - - {error && ( - - {error.message} - + )} + + + + {isCustomFee && ( + + + + )} + + + {error && ( + + {error.message} + + )} - {helperText && {helperText}} - - )} + {helperText && {helperText}} + ); }; diff --git a/packages/suite/src/hooks/wallet/useSendFormCompose.ts b/packages/suite/src/hooks/wallet/useSendFormCompose.ts index 78890a4e4cbd..27815c517110 100644 --- a/packages/suite/src/hooks/wallet/useSendFormCompose.ts +++ b/packages/suite/src/hooks/wallet/useSendFormCompose.ts @@ -55,6 +55,8 @@ export const useSendFormCompose = ({ const [composeField, setComposeField] = useState | undefined>(undefined); const [draftSaveRequest, setDraftSaveRequest] = useState(false); + const shouldUsePriorityFees = state.network.features.includes('eip1559'); + const dispatch = useDispatch(); const { translationString } = useTranslation(); @@ -78,6 +80,7 @@ export const useSendFormCompose = ({ feeInfo: state.feeInfo, excludedUtxos, prison, + shouldUsePriorityFees, }, }), ); @@ -89,7 +92,16 @@ export const useSendFormCompose = ({ setLoading(false); } }, - [account, dispatch, prison, excludedUtxos, setLoading, state.network, state.feeInfo], + [ + account, + dispatch, + prison, + excludedUtxos, + setLoading, + state.network, + state.feeInfo, + shouldUsePriorityFees, + ], ); // Create a compose request @@ -129,6 +141,7 @@ export const useSendFormCompose = ({ feeInfo: state.feeInfo, excludedUtxos, prison, + shouldUsePriorityFees, }, }), ); diff --git a/packages/suite/src/views/wallet/send/SendFees.tsx b/packages/suite/src/views/wallet/send/SendFees.tsx index aa1a0d63d19c..7931482cb172 100644 --- a/packages/suite/src/views/wallet/send/SendFees.tsx +++ b/packages/suite/src/views/wallet/send/SendFees.tsx @@ -14,6 +14,7 @@ export const SendFees = () => { account, feeInfo, composedLevels, + network, } = useSendFormContext(); return ( @@ -28,6 +29,7 @@ export const SendFees = () => { account={account} composedLevels={composedLevels} changeFeeLevel={changeFeeLevel} + network={network} /> ); diff --git a/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts b/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts index 1549a5f8224f..104a98842fe3 100644 --- a/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts @@ -48,7 +48,10 @@ const calculate = ( ): PrecomposedTransaction => { let amount: string; let max: string | undefined; - const feeInGwei = calculateEthFee(toWei(feeLevel.feePerUnit, 'gwei'), feeLevel.feeLimit || '0'); + const feeInGwei = calculateEthFee( + toWei(feeLevel.feePerUnit || '0', 'gwei'), + feeLevel.feeLimit || '0', + ); const availableTokenBalance = token ? amountToSmallestUnit(token.balance!, token.decimals) diff --git a/suite-common/wallet-core/src/send/sendFormThunks.ts b/suite-common/wallet-core/src/send/sendFormThunks.ts index 5ae5b5998a44..ae1d095ca899 100644 --- a/suite-common/wallet-core/src/send/sendFormThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormThunks.ts @@ -156,7 +156,10 @@ export const composeSendFormTransactionFeeLevelsThunk = createThunk< ); } else if (networkType === 'ethereum') { response = await dispatch( - composeEthereumTransactionFeeLevelsThunk({ formState, composeContext }), + composeEthereumTransactionFeeLevelsThunk({ + formState, + composeContext, + }), ); } else if (networkType === 'ripple') { response = await dispatch( diff --git a/suite-common/wallet-core/src/send/sendFormTypes.ts b/suite-common/wallet-core/src/send/sendFormTypes.ts index 111c882c726d..ad5f4d7caaaf 100644 --- a/suite-common/wallet-core/src/send/sendFormTypes.ts +++ b/suite-common/wallet-core/src/send/sendFormTypes.ts @@ -19,6 +19,7 @@ export interface ComposeActionContext { feeInfo: FeeInfo; excludedUtxos?: ExcludedUtxos; prison?: Record; + shouldUsePriorityFees: boolean; } export type EthTransactionData = { diff --git a/suite-common/wallet-types/src/sendForm.ts b/suite-common/wallet-types/src/sendForm.ts index 241617f1af66..041c436809b0 100644 --- a/suite-common/wallet-types/src/sendForm.ts +++ b/suite-common/wallet-types/src/sendForm.ts @@ -12,10 +12,23 @@ export type FormOptions = export type UtxoSorting = 'newestFirst' | 'oldestFirst' | 'smallestFirst' | 'largestFirst'; +export type EVMPriorityFeeLevel = { + label: 'high' | 'medium' | 'low'; + baseFeePerGas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + minWaitTimeEstimate: number; + maxWaitTimeEstimate: number; +}; + export interface FormState { outputs: Output[]; // output arrays, each element is corresponding with single Output item setMaxOutputId?: number; selectedFee?: FeeLevel['label']; + selectedPriority?: EVMPriorityFeeLevel; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + feePerUnit: string; // bitcoin/ethereum/ripple custom fee field (satB/gasPrice/drops) feeLimit: string; // ethereum only (gasLimit) estimatedFeeLimit?: string; // ethereum only (gasLimit)