diff --git a/docs/packages/suite/priority fees eip1559/eip1559.md b/docs/packages/suite/priority fees eip1559/eip1559.md new file mode 100644 index 00000000000..2eb2cc283e2 --- /dev/null +++ b/docs/packages/suite/priority fees eip1559/eip1559.md @@ -0,0 +1,59 @@ +# EIP-1559 - priority fees + +More info in [notion](https://www.notion.so/satoshilabs/EIP-1559-Priority-fees-177dc5260606801fbc82e05e0db9cb3e) + +- ✅ Ethereum Mainnet + Holesky + Sepolia +- ✅ Polygon PoS +- ✅ Arbitrum One +- ✅ Base +- ✅ Optimism +- ❌ BNB Smart Chain (supported by fee provider, not implemented in Suite - base fee is 0) + +# Blockbook + +It is possible to send EIP-1559 transaction, although when we receive transaction data, eip1559 information is not present. + +This has to be fixed on blockbook side. (Martin Böhm should be working on it right now) + +This is a blocker for sending RBF and correctly displaying transaction fees data in transaction details. + +# Passing EIP-1559 transaction to firmware + +### In Firmware maximum fee is calculated: + +https://github.com/trezor/trezor-firmware/blob/13c078f8af75135ac757423da3fc0c013186c32c/core/src/apps/ethereum/sign_tx_eip1559.py#L65 + +`maximum_fee = format_ethereum_amount(max_gas_fee * gas_limit, None, defs.network)` + +Since we can’t provide baseFeePerGas to firmware, we should calculate **_effective gas price_** on the suite side and pass the selected price to maximum_fee. Priority fee is being passed just for the purpose of displaying it, in fact it's being already included into effective gas price. + +### Misc + +I have some thought processes documented [here](https://excalidraw.com/#json=0l28PPN7iSp-ekzch1igV,iQnn7yW7gpQ-6mWcK1nUAw), although it's not complete, it can help you to understand how I reasoned about the forms. Which is the most challenging part about implementing this feature. + +While testing, I recommend having a list of each part of the app where you have the fees component and after you test it document the send transaction. + +### Staking + +#### Stake + +#### Unstake/Claim + +### Send + +### Swap + +#### Approval tx for tokens on DEX + +#### The swap tx on DEX + +#### CEX + +### Sell + +Each of the above scenarios has to be tested with: + +- legacy fees with backend disconnected/returning only normal fee level (standard and custom) +- legacy fees when the network feature is turned off +- priority fees with a standard fee level +- priority fees with custom fee level diff --git a/packages/connect/src/api/blockchainEstimateFee.ts b/packages/connect/src/api/blockchainEstimateFee.ts index 5bbd4181255..e974f42b76b 100644 --- a/packages/connect/src/api/blockchainEstimateFee.ts +++ b/packages/connect/src/api/blockchainEstimateFee.ts @@ -16,6 +16,17 @@ type Params = { request: Payload<'blockchainEstimateFee'>['request']; }; +const getFees = (coinInfo: CoinInfo) => { + switch (coinInfo.type) { + case 'bitcoin': + return new BitcoinFeeLevels(coinInfo); + case 'ethereum': + return new EthereumFeeLevels(coinInfo); + default: + return new MiscFeeLevels(coinInfo); + } +}; + export default class BlockchainEstimateFee extends AbstractMethod<'blockchainEstimateFee', Params> { init() { this.useDevice = false; @@ -73,23 +84,15 @@ export default class BlockchainEstimateFee extends AbstractMethod<'blockchainEst dustLimit: coinInfo.type === 'bitcoin' ? coinInfo.dustLimit : undefined, levels: [], }; - const getFees = () => { - switch (coinInfo.type) { - case 'bitcoin': - return new BitcoinFeeLevels(coinInfo); - case 'ethereum': - return new EthereumFeeLevels(coinInfo); - default: - return new MiscFeeLevels(coinInfo); - } - }; - if (request && request.feeLevels) { - const fees = getFees(); + if (request?.feeLevels) { + const fees = getFees(coinInfo); + if (request.feeLevels === 'smart') { const backend = await initBlockchain(coinInfo, this.postMessage, identity); await fees.load(backend); } + feeInfo.levels = fees.levels; } else { const backend = await initBlockchain(coinInfo, this.postMessage, identity); diff --git a/packages/connect/src/api/common/MiscFees.ts b/packages/connect/src/api/common/MiscFees.ts index 2ca47563519..dff7e2144f7 100644 --- a/packages/connect/src/api/common/MiscFees.ts +++ b/packages/connect/src/api/common/MiscFees.ts @@ -34,7 +34,7 @@ export class MiscFeeLevels { try { const [response] = await blockchain.estimateFee({ blocks: [1] }); - //misc coins should have only one FeeLevel (normal) + // misc coins should have only one FeeLevel (normal) this.levels[0] = { ...this.levels[0], ...response, diff --git a/packages/connect/src/api/ethereum/EthereumFees.ts b/packages/connect/src/api/ethereum/EthereumFees.ts index d44f9163bcb..046648e2fbf 100644 --- a/packages/connect/src/api/ethereum/EthereumFees.ts +++ b/packages/connect/src/api/ethereum/EthereumFees.ts @@ -2,10 +2,8 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import { Blockchain } from '../../backend/BlockchainLink'; import type { EthereumNetworkInfo, FeeLevel } from '../../types'; -import { Blocks, MiscFeeLevels, findBlocksForFee } from '../common/MiscFees'; +import { Blocks, MiscFeeLevels } from '../common/MiscFees'; -type EipResponse1559Level = 'low' | 'medium' | 'high'; -type Eip1559Level = 'low' | 'normal' | 'high'; export class EthereumFeeLevels extends MiscFeeLevels { coinInfo: EthereumNetworkInfo; levels: FeeLevel[]; @@ -20,51 +18,52 @@ export class EthereumFeeLevels extends MiscFeeLevels { async load(blockchain: Blockchain) { try { const [response] = await blockchain.estimateFee({ blocks: [1] }); - if (response.eip1559) { - const eip1559ResponseLevelKeys = [ - 'low', - 'medium', - 'high', - ] as EipResponse1559Level[]; - const { eip1559 } = response; - const eip1559Levels = eip1559ResponseLevelKeys.map(levelKey => { + const { eip1559 } = response; + + const maxFeeInWei = new BigNumber(this.coinInfo.maxFee).multipliedBy('1e+9').toNumber(); + const minFeeInWei = new BigNumber(this.coinInfo.minFee).multipliedBy('1e+9').toNumber(); + const feeInWei = new BigNumber(response.feePerUnit).toNumber(); + + // validate `feePerUnit` from the backend + const feePerUnit = Math.min(maxFeeInWei, Math.max(minFeeInWei, feeInWei)).toString(); + + if (eip1559) { + const levels = (['low', 'medium', 'high'] as const).map(levelKey => { const level = eip1559[levelKey]; // We can't pass BaseFeePerGas to firmware, so we calculate the effective gas price here - const calculatedMaxFeePerGas = BigNumber.minimum( - new BigNumber(level?.maxFeePerGas || '0'), - new BigNumber(eip1559.baseFeePerGas || '0').plus( - level?.maxPriorityFeePerGas || '0', - ), - ).toFixed(); + /* + const calculatedMaxBaseFeePerGas = BigNumber.minimum( + new BigNumber(level?.maxFeePerGas || '0'), + new BigNumber(eip1559.baseFeePerGas || '0').plus( + level?.maxPriorityFeePerGas || '0', + ), + ).toFixed(); + */ - const label = - levelKey === 'medium' - ? ('normal' as Eip1559Level) - : (levelKey as Eip1559Level); + const label = levelKey === 'medium' ? 'normal' : levelKey; return { - label, - maxFeePerGas: level?.maxFeePerGas || '0', - effectiveGasPrice: calculatedMaxFeePerGas, - maxPriorityFeePerGas: level?.maxPriorityFeePerGas || '0', - baseFeePerGas: eip1559.baseFeePerGas, - minWaitTimeEstimate: level?.minWaitTimeEstimate - ? level.minWaitTimeEstimate / 1000 - : undefined, // Infura provides wait time in miliseconds - maxWaitTimeEstimate: level?.maxWaitTimeEstimate - ? level.maxWaitTimeEstimate / 1000 - : undefined, - feePerUnit: '0', + label: label as FeeLevel['label'], + feePerUnit, feeLimit: response.feeLimit, blocks: -1, + baseFeePerGas: eip1559.baseFeePerGas, + maxFeePerGas: level?.maxFeePerGas, + maxPriorityFeePerGas: level?.maxPriorityFeePerGas, + minWaitTimeEstimate: level?.minWaitTimeEstimate, + maxWaitTimeEstimate: level?.maxWaitTimeEstimate, }; }); - this.levels = [...eip1559Levels]; + this.levels = [...levels]; } else { - super.load(blockchain); + this.levels[0] = { + ...this.levels[0], + ...response, + feePerUnit, + }; } } catch { // silent @@ -72,22 +71,4 @@ export class EthereumFeeLevels extends MiscFeeLevels { return this.levels; } - - updateEthereumCustomFee( - feePerUnit: string, - effectiveGasPrice?: string, - maxPriorityFeePerGas?: string, - ) { - // remove "custom" level from list - this.levels = this.levels.filter(l => l.label !== 'custom'); - // recreate "custom" level - const blocks = findBlocksForFee(feePerUnit, this.blocks); - this.levels.push({ - label: 'custom', - feePerUnit, - blocks, - maxPriorityFeePerGas, - effectiveGasPrice, - }); - } } diff --git a/packages/connect/src/types/fees.ts b/packages/connect/src/types/fees.ts index 821d10c2002..0da493cd2c3 100644 --- a/packages/connect/src/types/fees.ts +++ b/packages/connect/src/types/fees.ts @@ -8,14 +8,6 @@ export const FeeInfo = Type.Object({ dustLimit: Type.Number(), }); -export type PriorityFeeEstimationDetails = Static; -export const PriorityFeeEstimationDetails = Type.Object({ - maxFeePerGas: Type.String(), - maxPriorityFeePerGas: Type.String(), - maxWaitTimeEstimate: Type.Optional(Type.Number()), - minWaitTimeEstimate: Type.Optional(Type.Number()), -}); - export type FeeLevel = Static; export const FeeLevel = Type.Object({ label: Type.Union([ @@ -31,7 +23,8 @@ export const FeeLevel = Type.Object({ feePerTx: Type.Optional(Type.String()), // fee for BlockchainEstimateFeeParams.request.specific baseFeePerGas: Type.Optional(Type.String()), maxFeePerGas: Type.Optional(Type.String()), - effectiveGasPrice: Type.Optional(Type.String()), + customMaxBaseFeePerGas: Type.Optional(Type.String()), + customMaxPriorityFeePerGas: Type.Optional(Type.String()), maxPriorityFeePerGas: Type.Optional(Type.String()), maxWaitTimeEstimate: Type.Optional(Type.Number()), minWaitTimeEstimate: Type.Optional(Type.Number()), diff --git a/packages/suite/src/actions/wallet/stake/stakeFormActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormActions.ts index b060a882c0e..9e666cd2182 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormActions.ts @@ -81,6 +81,8 @@ export const calculate = ( max, fee: feeInBaseUnits, feePerByte: feeLevel.feePerUnit, + maxFeePerGas: feeLevel.effectiveGasPrice || undefined, + maxPriorityFeePerGas: feeLevel.maxPriorityFeePerGas || undefined, feeLimit: feeLevel.feeLimit, bytes: 0, inputs: [], diff --git a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts index c0370abe129..e2e459b0056 100644 --- a/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts +++ b/packages/suite/src/actions/wallet/stake/stakeFormEthereumActions.ts @@ -9,6 +9,7 @@ import { UNSTAKE_INTERCHANGES, } from '@suite-common/wallet-constants'; import { ComposeActionContext, selectSelectedDevice } from '@suite-common/wallet-core'; +import { calculateEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { AddressDisplayOptions, ExternalOutput, @@ -16,7 +17,7 @@ import { PrecomposedTransactionFinal, StakeFormState, } from '@suite-common/wallet-types'; -import { calculateEthFee, getAccountIdentity, isPending } from '@suite-common/wallet-utils'; +import { calculateMaxEthFee, getAccountIdentity, isPending } from '@suite-common/wallet-utils'; import TrezorConnect, { FeeLevel } from '@trezor/connect'; import { BigNumber } from '@trezor/utils/src/bigNumber'; @@ -31,14 +32,18 @@ import { import { calculate, composeStakingTransaction } from './stakeFormActions'; -const calculateTransaction = ( +const calculateStakingTransaction = ( availableBalance: string, output: ExternalOutput, feeLevel: FeeLevel, compareWithAmount = true, symbol: NetworkSymbol, ): PrecomposedTransaction => { - const feeInWei = calculateEthFee(toWei(feeLevel.feePerUnit, 'gwei'), feeLevel.feeLimit || '0'); + const isEip1559 = feeLevel.maxPriorityFeePerGas !== undefined; + + const feeInWei = isEip1559 + ? calculateMaxEthFee(feeLevel.effectiveGasPrice, feeLevel.feeLimit) + : calculateMaxEthFee(toWei(feeLevel.feePerUnit, 'gwei'), feeLevel.feeLimit); const stakingParams = { feeInBaseUnits: feeInWei, @@ -82,10 +87,18 @@ export const composeTransaction = } // in case when selectedFee is set to 'custom' construct this FeeLevel from values if (formValues.selectedFee === 'custom') { + const calculatedEffectiveGasPrice = calculateEffectiveGasPrice({ + maxFeePerGasGwei: formValues.customMaxBaseFeePerGas || '0', + maxPriorityFeePerGasGwei: formValues.customMaxPriorityFeePerGas, + }); predefinedLevels.push({ label: 'custom', feePerUnit: formValues.feePerUnit, feeLimit: formValues.feeLimit, + customMaxBaseFeePerGas: formValues.customMaxBaseFeePerGas, + customMaxPriorityFeePerGas: formValues.customMaxPriorityFeePerGas || '0', + effectiveGasPrice: calculatedEffectiveGasPrice, + maxPriorityFeePerGas: toWei(Number(formValues.customMaxPriorityFeePerGas), 'gwei'), blocks: -1, }); } @@ -94,7 +107,7 @@ export const composeTransaction = formValues, formState, predefinedLevels, - calculateTransaction, + calculateStakingTransaction, undefined, customFeeLimit, ); @@ -150,6 +163,8 @@ export const signTransaction = identity, amount: formValues.outputs[0].amount, gasPrice: transactionInfo.feePerByte, + maxFeePerGas: transactionInfo.maxFeePerGas, + maxPriorityFeePerGas: transactionInfo.maxPriorityFeePerGas, nonce, chainId: network.chainId, }); @@ -161,6 +176,8 @@ export const signTransaction = identity, amount: formValues.outputs[0].amount, gasPrice: transactionInfo.feePerByte, + maxFeePerGas: transactionInfo.maxFeePerGas, + maxPriorityFeePerGas: transactionInfo.maxPriorityFeePerGas, nonce, chainId: network.chainId, interchanges: UNSTAKE_INTERCHANGES, @@ -172,6 +189,8 @@ export const signTransaction = from: account.descriptor, identity, gasPrice: transactionInfo.feePerByte, + maxFeePerGas: transactionInfo.maxFeePerGas, + maxPriorityFeePerGas: transactionInfo.maxPriorityFeePerGas, nonce, chainId: network.chainId, }); 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 eecc9cc1e12..77afc49310e 100644 --- a/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx +++ b/packages/suite/src/components/suite/modals/ReduxModal/TransactionReviewModal/TransactionReviewSummary.tsx @@ -1,5 +1,5 @@ import { formatDurationStrict } from '@suite-common/suite-utils'; -import { NetworkType, networks } from '@suite-common/wallet-config'; +import { NetworkType, getNetworkFeatures, networks } from '@suite-common/wallet-config'; import { FeeInfo, GeneralPrecomposedTransactionFinal, StakeType } from '@suite-common/wallet-types'; import { getFee } from '@suite-common/wallet-utils'; import { Box, IconButton, Note, Row, Text } from '@trezor/components'; @@ -48,13 +48,21 @@ export const TransactionReviewSummary = ({ const locale = useLocales(); const { symbol, accountType, index, networkType } = account; const network = networks[symbol]; - const fee = getFee(networkType, tx); + + const hasEip1559Feature = getNetworkFeatures(symbol).includes('eip1559'); + + const shouldUsePriorityFees = + !!tx.fee && hasEip1559Feature && !!fees[symbol].levels[0].baseFeePerGas; + const fee = getFee({ account, tx, shouldUsePriorityFees }); + const estimateTime = getEstimatedTime(networkType, fees[account.symbol], tx); const formFeeRate = drafts[currentAccountKey]?.feePerUnit; const isFeeCustom = drafts[currentAccountKey]?.selectedFee === 'custom'; const isComposedFeeRateDifferent = isFeeCustom && formFeeRate !== fee; + const isEthereumNetworkType = networkType === 'ethereum'; + return ( @@ -82,17 +90,15 @@ export const TransactionReviewSummary = ({ )} - {networkType === 'ethereum' ? ( - - - {': '} - - - ) : ( - - - - )} + + {isEthereumNetworkType && ( + <> + + {': '} + + )} + + {isComposedFeeRateDifferent && network.networkType === 'bitcoin' && ( diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx index 9013f7de8eb..8c39800760c 100644 --- a/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CurrentFee.tsx @@ -10,12 +10,20 @@ type CurrentFeeProps = { feeIconName: IconName; currentFee: string; symbol: NetworkSymbol; + isEip1559?: boolean; }; -export const CurrentFee = ({ networkType, feeIconName, currentFee, symbol }: CurrentFeeProps) => ( +// For priority fees it should show current base fee +export const CurrentFee = ({ + networkType, + feeIconName, + currentFee, + symbol, + isEip1559 = false, +}: CurrentFeeProps) => ( - + diff --git a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx index 3d00bfa4dd6..3a1baa8635c 100644 --- a/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx +++ b/packages/suite/src/components/wallet/Fees/CustomFee/CustomFee.tsx @@ -6,6 +6,8 @@ import { UseFormSetValue, } from 'react-hook-form'; +import { fromWei } from 'web3-utils'; + import { NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; import { FeeInfo, FormState } from '@suite-common/wallet-types'; import { getFeeUnits, isInteger } from '@suite-common/wallet-utils'; @@ -75,7 +77,17 @@ export const CustomFee = ({ const locale = useSelector(selectLanguage); + const normalLevel = feeInfo.levels.filter(level => level.label === 'normal')[0]; + + const isEip1559 = normalLevel.maxPriorityFeePerGas !== undefined; + + const currentBaseFee = fromWei(Number(normalLevel.baseFeePerGas), 'Gwei'); + const getCurrentFee = () => { + if (isEip1559) { + return `${currentBaseFee}`; + } + const { levels } = feeInfo; const middleIndex = Math.floor((levels.length - 1) / 2); @@ -92,11 +104,14 @@ export const CustomFee = ({ feeIconName={feeIconName} currentFee={getCurrentFee()} symbol={symbol} + isEip1559={isEip1559} /> {networkType === 'ethereum' ? ( ({ networkType, feeInfo, @@ -20,8 +23,13 @@ export const CustomFeeEthereum = ({ translationString, feeUnits, sharedRules, + isEip1559, + currentBaseFee, ...props -}: CustomFeeBasicProps) => { +}: CustomFeeBasicProps & { + isEip1559: boolean; + currentBaseFee: string; +}) => { // 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; @@ -32,6 +40,9 @@ export const CustomFeeEthereum = ({ const feePerUnitError = errors.feePerUnit; const feeLimitError = errors.feeLimit; + const customMaxBaseFeePerGasError = errors.customMaxBaseFeePerGas; + const customMaxPriorityFeePerGasError = errors.customMaxPriorityFeePerGas; + const feeLimitRules = { required: translationString('GAS_LIMIT_IS_NOT_SET'), validate: { @@ -66,6 +77,27 @@ export const CustomFeeEthereum = ({ }, }; + const customMaxBaseFeePerGasRules = { + ...sharedRules, + validate: { + ...sharedRules.validate, + ethereumDecimalsLimit: feeRules.validate.ethereumDecimalsLimit, + // Base fee can't be lower than the current network base fee. + customMaxBaseFeePerGas: (value: string) => { + const baseFee = new BigNumber(value); + if (baseFee.isLessThan(currentBaseFee)) { + return translationString('TR_CUSTOM_FEE_BASE_FEE_BELOW_CURRENT'); + } + }, + }, + }; + + const customMaxPriorityFeePerGasRules = { + validate: { + ethereumDecimalsLimit: feeRules.validate.ethereumDecimalsLimit, + }, + }; + const feeLimitValidationProps = { onClick: () => estimatedFeeLimit && @@ -78,6 +110,20 @@ export const CustomFeeEthereum = ({ const feeLimitValidationButtonProps = feeLimitError?.type === 'feeLimit' ? feeLimitValidationProps : undefined; + const customMaxBaseFeeValidationProps = { + onClick: () => + estimatedFeeLimit && + setValue(CUSTOM_MAX_BASE_FEE_PER_GAS, currentBaseFee, { + shouldValidate: true, + }), + text: translationString('TR_CUSTOM_MAX_BASE_FEE_USE_NETWORK_BASE_FEE'), + }; + + const customMaxBaseFeeValidationButtonProps = + customMaxBaseFeePerGasError?.type === 'customMaxBaseFeePerGas' + ? customMaxBaseFeeValidationProps + : undefined; + const gasLimitInput = ( } @@ -98,6 +144,49 @@ export const CustomFeeEthereum = ({ /> ); + const eip1559InputFields = ( + <> + {/* Base fee per gas can't be lower than the current network base fee */} + } + locale={locale} + control={control} + inputState={getInputState(customMaxBaseFeePerGasError)} + innerAddon={ + + {feeUnits} + + } + name={CUSTOM_MAX_BASE_FEE_PER_GAS} + data-testid={CUSTOM_MAX_BASE_FEE_PER_GAS} + rules={customMaxBaseFeePerGasRules} + bottomText={ + customMaxBaseFeePerGasError?.message ? ( + + ) : null + } + /> + } + locale={locale} + control={control} + inputState={getInputState(customMaxPriorityFeePerGasError)} + innerAddon={ + + {feeUnits} + + } + name={CUSTOM_MAX_PRIORITY_FEE_PER_GAS} + data-testid={CUSTOM_MAX_PRIORITY_FEE_PER_GAS} + rules={customMaxPriorityFeePerGasRules} + bottomText={customMaxPriorityFeePerGasError?.message || null} + /> + + ); + const legacyEvmInputFields = ( } @@ -119,7 +208,7 @@ export const CustomFeeEthereum = ({ return ( <> {gasLimitInput} - {legacyEvmInputFields} + {isEip1559 ? eip1559InputFields : legacyEvmInputFields} ); }; diff --git a/packages/suite/src/components/wallet/Fees/Fees.tsx b/packages/suite/src/components/wallet/Fees/Fees.tsx index 9c63f62fc63..c27dbae1ec9 100644 --- a/packages/suite/src/components/wallet/Fees/Fees.tsx +++ b/packages/suite/src/components/wallet/Fees/Fees.tsx @@ -12,6 +12,10 @@ import styled from 'styled-components'; import { TranslationKey } from '@suite-common/intl-types'; import { NetworkSymbol, NetworkType } from '@suite-common/wallet-config'; +import { + calculateEffectiveGasPrice, + calculateEffectiveGasPriceGwei, +} from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { FeeInfo, FormState, @@ -45,6 +49,8 @@ export type FeeOptionType = { feePerUnit?: string; networkAmount?: string | null; feePerTx?: string; // Solana specific + maxWaitTimeEstimate?: number; // Ethereum specific + effectiveGasPrice?: string; // Ethereum specific }; const SelectBarWrapper = styled.div` @@ -110,8 +116,18 @@ const buildFeeOptions = ( }; }); case 'ethereum': - // legacy fee format - return filteredLevels.map(level => buildBasicFeeOptions(level)); + return filteredLevels.map(level => { + const basicFeeOption = buildBasicFeeOptions(level); + + return { + ...basicFeeOption, + maxWaitTimeEstimate: level.maxWaitTimeEstimate, + effectiveGasPrice: calculateEffectiveGasPriceGwei({ + maxFeePerGasGwei: level.baseFeePerGas, + maxPriorityFeePerGasGwei: level.maxPriorityFeePerGas, + }), + }; + }); case 'bitcoin': return filteredLevels.map(level => { const basicFeeOption = buildBasicFeeOptions(level); @@ -144,11 +160,15 @@ 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); + const isEip1559 = feeOptions.some(option => option.effectiveGasPrice); + const hasTransactionInfo = transactionInfo !== undefined && transactionInfo.type !== 'error'; const networkAmount = hasTransactionInfo ? formatNetworkAmount(transactionInfo.fee, symbol) @@ -220,7 +240,7 @@ export const Fees = ({ )} - {!isCustomFee && selectedLevel && ( + {!isCustomFee && ( ({ - : + : {networkAmount && ( diff --git a/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx b/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx index 2c04ad0d0fe..43b915ef92c 100644 --- a/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx +++ b/packages/suite/src/components/wallet/Fees/StandardFee/EthereumFeeCards.tsx @@ -1,7 +1,12 @@ +import { fromWei } from 'web3-utils'; + +import { formatDurationStrict } from '@suite-common/suite-utils'; import { FeeRate } from '@trezor/product-components'; import { FiatValue } from 'src/components/suite'; +import { useLocales } from 'src/hooks/suite'; +import { FeeOptionType } from '../Fees'; import { FeeCard } from './FeeCard'; import { StandardFeeProps } from './StandardFee'; @@ -13,29 +18,52 @@ export const EthereumFeeCards = ({ symbol, networkType, }: StandardFeeProps) => { + const locale = useLocales(); if (!showFee || !feeOptions.length) { return null; } - return feeOptions.map((fee, index) => ( - {fee.label}} - topRightChild="" - bottomLeftChild={ - { + if (isEip1559 && fee.maxWaitTimeEstimate) { + return `~${formatDurationStrict(fee.maxWaitTimeEstimate / 1000, locale)}`; + } + + return ''; + }; + + return ( + <> + {feeOptions?.map((fee, index) => ( + {fee.label}} + topRightChild={getTimeEstimate(fee)} + bottomLeftChild={ + + } + bottomRightChild={ + + } /> - } - bottomRightChild={ - - } - /> - )); + ))} + + ); }; diff --git a/packages/suite/src/hooks/wallet/form/useCompose.ts b/packages/suite/src/hooks/wallet/form/useCompose.ts index 2084d610185..c9f22ea95ce 100644 --- a/packages/suite/src/hooks/wallet/form/useCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useCompose.ts @@ -2,12 +2,14 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; import { isFulfilled } from '@reduxjs/toolkit'; +import { fromWei } from 'web3-utils'; import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants'; import { ComposeActionContext, composeSendFormTransactionFeeLevelsThunk, } from '@suite-common/wallet-core'; +import { calculateBaseFeeFromEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { FormState, PrecomposedLevels, @@ -170,7 +172,17 @@ export const useCompose = ({ const prevLevel = composedLevels[prev || 'normal']; const levels = { ...composedLevels, - custom: prevLevel, + custom: { + ...prevLevel, + maxFeePerGas: + 'effectiveGasPrice' in prevLevel + ? prevLevel.effectiveGasPrice + : undefined, + maxPriorityFeePerGas: + 'maxPriorityFeePerGas' in prevLevel + ? prevLevel.maxPriorityFeePerGas + : undefined, + }, } as | (PrecomposedLevels & { custom: PrecomposedTransaction }) | (PrecomposedLevelsCardano & { custom: PrecomposedTransactionCardano }); @@ -204,9 +216,28 @@ export const useCompose = ({ setValue('selectedFee', nearest); if (nearest === 'custom') { // @ts-expect-error: type = error already filtered above - const { feePerByte, feeLimit } = composed; + const { feePerByte, feeLimit, effectiveGasPrice, maxPriorityFeePerGas } = + composed; setValue('feePerUnit', feePerByte); setValue('feeLimit', feeLimit || ''); + setValue('maxFeePerGas', effectiveGasPrice || ''); + setValue('maxPriorityFeePerGas', maxPriorityFeePerGas || ''); + setValue( + 'customMaxBaseFeePerGas', + fromWei( + Number( + calculateBaseFeeFromEffectiveGasPrice({ + effectiveGasPriceWei: effectiveGasPrice || '0', + maxPriorityFeePerGasWei: maxPriorityFeePerGas || '0', + }), + ), + 'gwei', + ), + ); + setValue( + 'customMaxPriorityFeePerGas', + fromWei(maxPriorityFeePerGas || '0', 'gwei'), + ); } } // or do nothing, use default composed tx diff --git a/packages/suite/src/hooks/wallet/form/useFees.ts b/packages/suite/src/hooks/wallet/form/useFees.ts index a024311fcf3..3a545af5309 100644 --- a/packages/suite/src/hooks/wallet/form/useFees.ts +++ b/packages/suite/src/hooks/wallet/form/useFees.ts @@ -1,6 +1,9 @@ import { useEffect, useRef } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; +import { fromWei, toWei } from 'web3-utils'; + +import { calculateEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { FeeInfo, FormState, @@ -41,8 +44,11 @@ export const useFees = ({ const selectedFeeRef = useRef(defaultValue); const feePerUnitRef = useRef(''); const feeLimitRef = useRef(''); + const customMaxPriorityFeePerGasRef = useRef(''); + const customMaxBaseFeePerGasRef = useRef(''); const estimatedFeeLimitRef = useRef(''); const saveLastUsedFeeRef = useRef(saveLastUsedFee); + const effectiveGasPriceRef = useRef(''); // Type assertion allowing to make the component reusable, see https://stackoverflow.com/a/73624072. const { clearErrors, getValues, register, setValue, watch } = @@ -52,6 +58,7 @@ export const useFees = ({ useEffect(() => { register('selectedFee', { shouldUnregister: true }); register('estimatedFeeLimit', { shouldUnregister: true }); + register('effectiveGasPrice', { shouldUnregister: true }); }, [register]); // watch selectedFee change and update local references @@ -59,15 +66,29 @@ export const useFees = ({ useEffect(() => { if (selectedFeeRef.current === selectedFee) return; selectedFeeRef.current = selectedFee; - const { feePerUnit, feeLimit } = getValues(); + const { + feePerUnit, + feeLimit, + customMaxPriorityFeePerGas, + customMaxBaseFeePerGas, + effectiveGasPrice, // is used here only to update it in the last used fee level + } = getValues(); feePerUnitRef.current = feePerUnit; feeLimitRef.current = feeLimit; + customMaxPriorityFeePerGasRef.current = customMaxPriorityFeePerGas; + customMaxBaseFeePerGasRef.current = customMaxBaseFeePerGas; + effectiveGasPriceRef.current = effectiveGasPrice; }, [selectedFee, getValues]); // watch custom feePerUnit/feeLimit inputs change const feePerUnit = watch('feePerUnit'); const feeLimit = watch('feeLimit'); const baseFee = watch('baseFee'); + const customMaxPriorityFeePerGas = watch('customMaxPriorityFeePerGas'); + const customMaxBaseFeePerGas = watch('customMaxBaseFeePerGas'); + const maxPriorityFeePerGas = watch('maxPriorityFeePerGas'); + const maxFeePerGas = watch('maxFeePerGas'); + const effectiveGasPrice = watch('effectiveGasPrice'); useEffect(() => { if (selectedFeeRef.current !== 'custom') return; @@ -82,7 +103,32 @@ export const useFees = ({ updateField = 'feeLimit'; } - // compose + if ( + customMaxBaseFeePerGas && + customMaxPriorityFeePerGas && + (customMaxPriorityFeePerGasRef.current !== customMaxPriorityFeePerGas || + customMaxBaseFeePerGasRef.current !== customMaxBaseFeePerGas) + ) { + const effectiveGasPriceNew = calculateEffectiveGasPriceGwei({ + maxPriorityFeePerGasGwei: customMaxPriorityFeePerGas, + maxFeePerGasGwei: customMaxBaseFeePerGas, + }); + setValue('effectiveGasPrice', effectiveGasPriceNew, { shouldValidate: true }); + effectiveGasPriceRef.current = effectiveGasPriceNew; + updateField = 'effectiveGasPrice'; + } + + if (customMaxBaseFeePerGasRef.current !== customMaxBaseFeePerGas) { + customMaxBaseFeePerGasRef.current = customMaxBaseFeePerGas; + updateField = 'customMaxBaseFeePerGas'; + } + + if (customMaxPriorityFeePerGasRef.current !== customMaxPriorityFeePerGas) { + customMaxPriorityFeePerGasRef.current = customMaxPriorityFeePerGas; + updateField = 'customMaxPriorityFeePerGas'; + } + + //compose if (updateField) { if (composeRequest) { composeRequest(updateField); @@ -95,11 +141,31 @@ export const useFees = ({ !errors.feeLimit ) { dispatch( - setLastUsedFeeLevel({ label: 'custom', feePerUnit, feeLimit, blocks: -1 }), + setLastUsedFeeLevel({ + label: 'custom', + feePerUnit, + feeLimit, + blocks: -1, + maxPriorityFeePerGas: toWei(customMaxPriorityFeePerGas || '0', 'gwei'), + effectiveGasPrice, + }), ); } } - }, [dispatch, feePerUnit, feeLimit, errors.feePerUnit, errors.feeLimit, composeRequest]); + }, [ + dispatch, + feePerUnit, + effectiveGasPrice, + feeLimit, + customMaxPriorityFeePerGas, + customMaxBaseFeePerGas, + maxPriorityFeePerGas, + maxFeePerGas, + errors.feePerUnit, + errors.feeLimit, + composeRequest, + setValue, + ]); // watch estimatedFee change const estimatedFeeLimit = watch('estimatedFeeLimit'); @@ -121,6 +187,8 @@ export const useFees = ({ let feePerUnit; let feeLimit; + let maxPriorityFeePerGas; + let baseFeePerGas; if (level === 'custom') { // switching to custom FeeLevel for the first time const currentLevel = feeInfo.levels.find( @@ -134,12 +202,19 @@ export const useFees = ({ ? transactionInfo.feePerByte : currentLevel.feePerUnit; feeLimit = getValues('estimatedFeeLimit') || currentLevel.feeLimit || ''; + maxPriorityFeePerGas = currentLevel.maxPriorityFeePerGas || undefined; + baseFeePerGas = currentLevel.baseFeePerGas || undefined; } else if (selectedFeeRef.current === 'custom' && (errors.feePerUnit || errors.feeLimit)) { // switching from custom FeeLevel which has an error // error should be cleared and levels should be precomposed again feePerUnit = ''; feeLimit = ''; - clearErrors(['feePerUnit', 'feeLimit']); + clearErrors([ + 'feePerUnit', + 'feeLimit', + 'customMaxPriorityFeePerGas', + 'customMaxBaseFeePerGas', + ]); composeRequest(); } @@ -152,6 +227,16 @@ export const useFees = ({ setValue('feeLimit', feeLimit); } + if (typeof maxPriorityFeePerGas === 'string') { + customMaxPriorityFeePerGasRef.current = fromWei(maxPriorityFeePerGas, 'gwei'); + setValue('customMaxPriorityFeePerGas', fromWei(maxPriorityFeePerGas, 'gwei')); + } + + if (typeof baseFeePerGas === 'string') { + customMaxBaseFeePerGasRef.current = fromWei(baseFeePerGas, 'gwei'); + setValue('customMaxBaseFeePerGas', fromWei(baseFeePerGas, 'gwei')); + } + // on change callback if (onChange) onChange(selectedFeeRef.current, level); diff --git a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts index 250e3d70772..ca004b09fe4 100644 --- a/packages/suite/src/hooks/wallet/form/useStakeCompose.ts +++ b/packages/suite/src/hooks/wallet/form/useStakeCompose.ts @@ -1,8 +1,11 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { FieldPath, UseFormReturn } from 'react-hook-form'; +import { fromWei } from 'web3-utils'; + import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants'; import { ComposeActionContext, StakeContextValues } from '@suite-common/wallet-core'; +import { calculateBaseFeeFromEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { PrecomposedLevels, PrecomposedTransaction, @@ -184,9 +187,22 @@ export const useStakeCompose = ({ setValue('selectedFee', nearest); if (nearest === 'custom') { // @ts-expect-error: type = error already filtered above - const { feePerByte, feeLimit } = composed; + const { feePerByte, feeLimit, effectiveGasPrice, maxPriorityFeePerGas } = + composed; + setValue('feePerUnit', feePerByte); setValue('feeLimit', feeLimit || ''); + setValue( + 'customMaxPriorityFeePerGas', + fromWei(maxPriorityFeePerGas, 'gwei') || '', + ); + setValue( + 'customMaxBaseFeePerGas', + calculateBaseFeeFromEffectiveGasPrice({ + effectiveGasPriceWei: effectiveGasPrice, + maxPriorityFeePerGasWei: maxPriorityFeePerGas, + }), + ); } } // or do nothing, use default composed tx diff --git a/packages/suite/src/hooks/wallet/useSendForm.ts b/packages/suite/src/hooks/wallet/useSendForm.ts index c8d32d7b949..0241cfba984 100644 --- a/packages/suite/src/hooks/wallet/useSendForm.ts +++ b/packages/suite/src/hooks/wallet/useSendForm.ts @@ -124,8 +124,13 @@ export const useSendForm = (props: UseSendFormProps): SendContextValues => { if (lastUsedFee) { feeEnhancement.selectedFee = lastUsedFee.label; if (lastUsedFee.label === 'custom') { - feeEnhancement.feePerUnit = lastUsedFee.feePerUnit; + feeEnhancement.feePerUnit = lastUsedFee.feePerUnit ?? '0'; feeEnhancement.feeLimit = lastUsedFee.feeLimit; + feeEnhancement.customMaxPriorityFeePerGas = + lastUsedFee.customMaxPriorityFeePerGas; + feeEnhancement.customMaxBaseFeePerGas = lastUsedFee.customMaxBaseFeePerGas; + feeEnhancement.maxFeePerGas = lastUsedFee.maxFeePerGas; + feeEnhancement.maxPriorityFeePerGas = lastUsedFee.maxPriorityFeePerGas; } } } diff --git a/packages/suite/src/hooks/wallet/useSendFormCompose.ts b/packages/suite/src/hooks/wallet/useSendFormCompose.ts index a874107d3ec..1cdb36b5409 100644 --- a/packages/suite/src/hooks/wallet/useSendFormCompose.ts +++ b/packages/suite/src/hooks/wallet/useSendFormCompose.ts @@ -3,9 +3,11 @@ import { FieldPath, UseFormReturn } from 'react-hook-form'; import { useDispatch } from 'react-redux'; import { isFulfilled } from '@reduxjs/toolkit'; +import { fromWei } from 'web3-utils'; import { COMPOSE_ERROR_TYPES } from '@suite-common/wallet-constants'; import { composeSendFormTransactionFeeLevelsThunk } from '@suite-common/wallet-core'; +import { calculateBaseFeeFromEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { ExcludedUtxos, FormState, @@ -262,9 +264,20 @@ export const useSendFormCompose = ({ setValue('selectedFee', nearest); if (nearest === 'custom') { // @ts-expect-error: type = error already filtered above - const { feePerByte, feeLimit } = composed; + const { feePerByte, feeLimit, maxPriorityFeePerGas, maxFeePerGas } = composed; setValue('feePerUnit', feePerByte); setValue('feeLimit', feeLimit || ''); + setValue( + 'customMaxPriorityFeePerGas', + fromWei(maxPriorityFeePerGas || '0', 'gwei'), + ); + setValue( + 'customMaxBaseFeePerGas', + calculateBaseFeeFromEffectiveGasPrice({ + effectiveGasPriceWei: maxFeePerGas || '0', + maxPriorityFeePerGasWei: maxPriorityFeePerGas || '0', + }), + ); } setDraftSaveRequest(true); } diff --git a/packages/suite/src/hooks/wallet/useTradingRecomposeAndSign.ts b/packages/suite/src/hooks/wallet/useTradingRecomposeAndSign.ts index a81566c6eea..363a1d0b067 100644 --- a/packages/suite/src/hooks/wallet/useTradingRecomposeAndSign.ts +++ b/packages/suite/src/hooks/wallet/useTradingRecomposeAndSign.ts @@ -1,9 +1,12 @@ import { useCallback } from 'react'; +import { fromWei } from 'web3-utils'; + import { notificationsActions } from '@suite-common/toast-notifications'; import { networks } from '@suite-common/wallet-config'; import { DEFAULT_PAYMENT, DEFAULT_VALUES } from '@suite-common/wallet-constants'; import { composeSendFormTransactionFeeLevelsThunk } from '@suite-common/wallet-core'; +import { calculateBaseFeeFromEffectiveGasPrice } from '@suite-common/wallet-core/src/send/sendFormEthereumUtils'; import { FormState } from '@suite-common/wallet-types'; import type { Account, FormOptions } from '@suite-common/wallet-types'; import { getFeeInfo } from '@suite-common/wallet-utils'; @@ -57,6 +60,13 @@ export const useTradingRecomposeAndSign = () => { } // prepare the fee levels, set custom values from composed // WORKAROUND: sendFormEthereumActions and sendFormRippleActions use form outputs instead of composed transaction data + const customMaxBaseFeePerGas = fromWei( + calculateBaseFeeFromEffectiveGasPrice({ + effectiveGasPriceWei: composed.maxFeePerGas || '0', + maxPriorityFeePerGasWei: composed.maxPriorityFeePerGas, + }) || '0', + 'gwei', + ); const formState: FormState = { ...DEFAULT_VALUES, outputs: [ @@ -70,6 +80,17 @@ export const useTradingRecomposeAndSign = () => { setMaxOutputId: !composed.token?.contract ? setMaxOutputId : undefined, selectedFee, feePerUnit: composed.feePerByte, + maxFeePerGas: + calculateBaseFeeFromEffectiveGasPrice({ + effectiveGasPriceWei: composed.maxFeePerGas || '0', + maxPriorityFeePerGasWei: composed.maxPriorityFeePerGas, + }) || '0', + maxPriorityFeePerGas: composed.maxPriorityFeePerGas, + customMaxBaseFeePerGas, + customMaxPriorityFeePerGas: composed.maxPriorityFeePerGas + ? fromWei(composed.maxPriorityFeePerGas, 'gwei') + : undefined, + effectiveGasPrice: composed.maxFeePerGas, feeLimit: composed.feeLimit || '', estimatedFeeLimit: composed.estimatedFeeLimit, options, diff --git a/packages/suite/src/hooks/wallet/useTradingRedirect.ts b/packages/suite/src/hooks/wallet/useTradingRedirect.ts index b6ad9225583..fe0175482f7 100644 --- a/packages/suite/src/hooks/wallet/useTradingRedirect.ts +++ b/packages/suite/src/hooks/wallet/useTradingRedirect.ts @@ -52,6 +52,8 @@ interface ExchangeOfferRedirectParams { selectedFee?: FeeLevel['label']; feePerByte?: string; feeLimit?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; } interface DetailRedirectParams { @@ -158,6 +160,8 @@ export const useTradingRedirect = () => { orderId, feeLimit, feePerByte, + maxFeePerGas, + maxPriorityFeePerGas, selectedFee, } = params; const request: ExchangeTradeQuoteRequest = { @@ -172,6 +176,8 @@ export const useTradingRedirect = () => { feeLimit, feePerByte: feePerByte || '', fee: '', // fee is not passed by redirect, will be recalculated + maxFeePerGas, + maxPriorityFeePerGas, }; dispatch(saveComposedTransactionInfo({ selectedFee: selectedFee || 'normal', composed })); dispatch(tradingExchangeActions.saveTransactionId(orderId)); diff --git a/packages/suite/src/reducers/wallet/tradingReducer.ts b/packages/suite/src/reducers/wallet/tradingReducer.ts index 044626d42c3..ba0957810b6 100644 --- a/packages/suite/src/reducers/wallet/tradingReducer.ts +++ b/packages/suite/src/reducers/wallet/tradingReducer.ts @@ -37,7 +37,14 @@ import { Action } from 'src/types/suite'; export interface ComposedTransactionInfo { composed?: Pick< PrecomposedTransactionFinal, - 'feePerByte' | 'estimatedFeeLimit' | 'feeLimit' | 'token' | 'fee' + | 'feePerByte' + | 'estimatedFeeLimit' + | 'feeLimit' + | 'token' + | 'fee' + | 'maxFeePerGas' + | 'maxPriorityFeePerGas' + | 'effectiveGasPrice' >; selectedFee?: FeeLevel['label']; } diff --git a/packages/suite/src/support/messages.ts b/packages/suite/src/support/messages.ts index 7a450363618..34a2b48a5b6 100644 --- a/packages/suite/src/support/messages.ts +++ b/packages/suite/src/support/messages.ts @@ -3495,6 +3495,18 @@ export default defineMessages({ id: 'TR_CURRENT_FEE_CUSTOM_FEES', defaultMessage: 'Current network fee:', }, + TR_CURRENT_BASE_FEE: { + id: 'TR_CURRENT_BASE_FEE', + defaultMessage: 'Current network base fee:', + }, + TR_MAX_BASE_FEE_PER_GAS: { + id: 'TR_MAX_BASE_FEE_PER_GAS', + defaultMessage: 'Max base fee', + }, + TR_CUSTOM_MAX_BASE_FEE_USE_NETWORK_BASE_FEE: { + id: 'TR_CUSTOM_MAX_BASE_FEE_USE_NETWORK_BASE_FEE', + defaultMessage: 'Use network base fee', + }, GAS_LIMIT_IS_NOT_SET: { id: 'GAS_LIMIT_IS_NOT_SET', defaultMessage: 'Set gas limit for this transaction', @@ -5648,6 +5660,10 @@ export default defineMessages({ description: 'Label in Send form for Ethereum network type', id: 'MAX_FEE', }, + WHY_FEES: { + defaultMessage: 'Why fees?', + id: 'WHY_FEES', + }, EXPECTED_FEE: { defaultMessage: 'Expected fee', description: 'Label in Send form for Solana network type', @@ -5673,6 +5689,18 @@ export default defineMessages({ defaultMessage: 'Low', id: 'FEE_LEVEL_LOW', }, + FEE_LEVEL_MEDIUM: { + defaultMessage: 'Medium', + id: 'FEE_LEVEL_MEDIUM', + }, + TR_MAX_PRIORITY_FEE_PER_GAS: { + defaultMessage: 'Priority fee', + id: 'TR_MAX_PRIORITY_FEE_PER_GAS', + }, + TR_MAX_FEE_PER_GAS: { + defaultMessage: 'Max fee', + id: 'TR_MAX_FEE_PER_GAS', + }, CUSTOM_FEE_IS_NOT_SET: { defaultMessage: 'Enter the fee rate you want to spend in order to complete this transaction.', @@ -5686,6 +5714,10 @@ export default defineMessages({ defaultMessage: 'Enter a fee between {minFee} and {maxFee}', id: 'CUSTOM_FEE_NOT_IN_RANGE', }, + TR_CUSTOM_FEE_BASE_FEE_BELOW_CURRENT: { + defaultMessage: 'Custom base fee can not be below current network base fee.', + id: 'TR_CUSTOM_FEE_BASE_FEE_BELOW_CURRENT', + }, CUSTOM_FEE_LIMIT_BELOW_RECOMMENDED: { defaultMessage: 'Gas limit too low', id: 'CUSTOM_FEE_LIMIT_BELOW_RECOMMENDED', diff --git a/packages/suite/src/utils/suite/__tests__/ethereumStaking.test.ts b/packages/suite/src/utils/suite/__tests__/ethereumStaking.test.ts index 39f9c78eaf7..3954bfdbbd4 100644 --- a/packages/suite/src/utils/suite/__tests__/ethereumStaking.test.ts +++ b/packages/suite/src/utils/suite/__tests__/ethereumStaking.test.ts @@ -56,7 +56,12 @@ import { describe('transformTx', () => { transformTxFixtures.forEach(test => { it(test.description, () => { - const result = transformTx(test.tx, test.gasPrice, test.nonce, test.chainId); + const result = transformTx({ + tx: test.tx, + gasPrice: test.gasPrice, + nonce: test.nonce, + chainId: test.chainId, + }); expect(result).toEqual(test.result); expect(result).not.toHaveProperty('from'); }); diff --git a/packages/suite/src/utils/suite/ethereumStaking.ts b/packages/suite/src/utils/suite/ethereumStaking.ts index 3c64a595231..10be1f414da 100644 --- a/packages/suite/src/utils/suite/ethereumStaking.ts +++ b/packages/suite/src/utils/suite/ethereumStaking.ts @@ -23,7 +23,12 @@ import { isSupportedEthStakingNetworkSymbol, sanitizeHex, } from '@suite-common/wallet-utils'; -import TrezorConnect, { EthereumTransaction, InternalTransfer, Success } from '@trezor/connect'; +import TrezorConnect, { + EthereumTransaction, + EthereumTransactionEIP1559, + InternalTransfer, + Success, +} from '@trezor/connect'; import { BlockchainEstimatedFee } from '@trezor/connect/src/types/api/blockchainEstimateFee'; import { PartialRecord } from '@trezor/type-utils'; import { BigNumber } from '@trezor/utils/src/bigNumber'; @@ -297,22 +302,48 @@ export const getStakeFormsDefaultValues = ({ selectedUtxos: [], }); -export const transformTx = ( - tx: any, - gasPrice: string, - nonce: string, - chainId: number, -): EthereumTransaction => { - const transformedTx = { - ...tx, - gasLimit: numberToHex(tx.gasLimit), - gasPrice: numberToHex(toWei(gasPrice, 'gwei')), - nonce: numberToHex(nonce), - chainId, - data: sanitizeHex(tx.data), - // in send form, the amount is in ether, here in wei because it is converted earlier in stake, unstake, claimToWithdraw methods - value: numberToHex(tx.value), - }; +type TransformTxParams = { + tx: any; + gasPrice: string | undefined; + nonce: string; + chainId: number; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; +}; +export const transformTx = ({ + tx, + gasPrice, + nonce, + chainId, + maxFeePerGas, + maxPriorityFeePerGas, +}: TransformTxParams): EthereumTransaction | EthereumTransactionEIP1559 => { + let transformedTx; + + if (maxFeePerGas && maxPriorityFeePerGas) { + transformedTx = { + ...tx, + gasLimit: numberToHex(tx.gasLimit), + gasPrice: undefined, + nonce: numberToHex(nonce), + chainId, + data: sanitizeHex(tx.data), + maxFeePerGas: numberToHex(maxFeePerGas), + maxPriorityFeePerGas: numberToHex(maxPriorityFeePerGas), + value: numberToHex(tx.value), + }; + } else { + transformedTx = { + ...tx, + gasLimit: numberToHex(tx.gasLimit), + gasPrice: numberToHex(toWei(gasPrice || '0', 'gwei')), + nonce: numberToHex(nonce), + chainId, + data: sanitizeHex(tx.data), + // in send form, the amount is in ether, here in wei because it is converted earlier in stake, unstake, claimToWithdraw methods + value: numberToHex(tx.value), + }; + } delete transformedTx.from; return transformedTx; @@ -323,14 +354,16 @@ interface PrepareStakeEthTxParams { identity?: string; from: string; amount: string; - gasPrice: string; + gasPrice: string | undefined; nonce: string; chainId: number; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; } export type PrepareStakeEthTxResponse = | { success: true; - tx: EthereumTransaction; + tx: EthereumTransaction | EthereumTransactionEIP1559; } | { success: false; @@ -344,6 +377,8 @@ export const prepareStakeEthTx = async ({ gasPrice, nonce, chainId, + maxFeePerGas, + maxPriorityFeePerGas, identity, }: PrepareStakeEthTxParams): Promise => { try { @@ -353,7 +388,14 @@ export const prepareStakeEthTx = async ({ symbol, identity, }); - const transformedTx = transformTx(tx, gasPrice, nonce, chainId); + const transformedTx = transformTx({ + tx, + gasPrice, + nonce, + chainId, + maxFeePerGas, + maxPriorityFeePerGas, + }); return { success: true, @@ -382,6 +424,8 @@ export const prepareUnstakeEthTx = async ({ chainId, identity, interchanges = UNSTAKE_INTERCHANGES, + maxFeePerGas, + maxPriorityFeePerGas, }: PrepareUnstakeEthTxParams): Promise => { try { const tx = await unstake({ @@ -391,7 +435,14 @@ export const prepareUnstakeEthTx = async ({ interchanges, symbol, }); - const transformedTx = transformTx(tx, gasPrice, nonce, chainId); + const transformedTx = transformTx({ + tx, + gasPrice, + nonce, + chainId, + maxFeePerGas, + maxPriorityFeePerGas, + }); return { success: true, @@ -416,10 +467,19 @@ export const prepareClaimEthTx = async ({ gasPrice, nonce, chainId, + maxFeePerGas, + maxPriorityFeePerGas, }: PrepareClaimEthTxParams): Promise => { try { const tx = await claimWithdrawRequest({ from, symbol, identity }); - const transformedTx = transformTx(tx, gasPrice, nonce, chainId); + const transformedTx = transformTx({ + tx, + gasPrice, + nonce, + chainId, + maxFeePerGas, + maxPriorityFeePerGas, + }); return { success: true, diff --git a/suite-common/wallet-config/src/networksConfig.ts b/suite-common/wallet-config/src/networksConfig.ts index c0446fa609b..bea3e6b8524 100644 --- a/suite-common/wallet-config/src/networksConfig.ts +++ b/suite-common/wallet-config/src/networksConfig.ts @@ -57,6 +57,7 @@ export const networks = { 'coin-definitions', 'nft-definitions', 'staking', + // 'eip1559', ], backendTypes: ['blockbook'], accountTypes: { @@ -87,7 +88,15 @@ export const networks = { decimals: 18, testnet: false, explorer: getExplorerUrls('https://pol1.trezor.io', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'coin-definitions', 'nft-definitions'], + features: [ + 'rbf', + 'sign-verify', + 'tokens', + 'nfts', + 'coin-definitions', + 'nft-definitions', + 'eip1559', + ], backendTypes: ['blockbook'], accountTypes: { ledger: { @@ -136,7 +145,15 @@ export const networks = { decimals: 18, testnet: false, explorer: getExplorerUrls('https://arbiscan.io', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'coin-definitions', 'nft-definitions'], + features: [ + 'rbf', + 'sign-verify', + 'tokens', + 'nfts', + 'coin-definitions', + 'nft-definitions', + 'eip1559', + ], backendTypes: ['blockbook'], accountTypes: { ledger: { @@ -161,7 +178,15 @@ export const networks = { decimals: 18, testnet: false, explorer: getExplorerUrls('https://basescan.org', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'coin-definitions', 'nft-definitions'], + features: [ + 'rbf', + 'sign-verify', + 'tokens', + 'nfts', + 'coin-definitions', + 'nft-definitions', + 'eip1559', + ], backendTypes: ['blockbook'], accountTypes: { ledger: { @@ -186,7 +211,15 @@ export const networks = { decimals: 18, testnet: false, explorer: getExplorerUrls('https://optimistic.etherscan.io', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'coin-definitions', 'nft-definitions'], + features: [ + 'rbf', + 'sign-verify', + 'tokens', + 'nfts', + 'coin-definitions', + 'nft-definitions', + 'eip1559', + ], backendTypes: ['blockbook'], accountTypes: { ledger: { @@ -446,7 +479,7 @@ export const networks = { decimals: 18, testnet: true, explorer: getExplorerUrls('https://sepolia1.trezor.io', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'nfts', 'nft-definitions', 'eip1559'], backendTypes: ['blockbook'], accountTypes: {}, coingeckoId: undefined, @@ -462,7 +495,7 @@ export const networks = { decimals: 18, testnet: true, explorer: getExplorerUrls('https://holesky1.trezor.io', 'ethereum'), - features: ['rbf', 'sign-verify', 'tokens', 'staking', 'nfts', 'nft-definitions'], + features: ['rbf', 'sign-verify', 'tokens', 'staking', 'nfts', 'nft-definitions', 'eip1559'], backendTypes: ['blockbook'], accountTypes: {}, coingeckoId: undefined, diff --git a/suite-common/wallet-config/src/types.ts b/suite-common/wallet-config/src/types.ts index 5ce28ab737a..c2fa73bd0ed 100644 --- a/suite-common/wallet-config/src/types.ts +++ b/suite-common/wallet-config/src/types.ts @@ -58,7 +58,8 @@ export type NetworkFeature = | 'tokens' | 'staking' | 'coin-definitions' - | 'nft-definitions'; + | 'nft-definitions' + | 'eip1559'; type Level = `/${number}'`; type MaybeApostrophe = `'` | ''; diff --git a/suite-common/wallet-core/src/blockchain/blockchainThunks.ts b/suite-common/wallet-core/src/blockchain/blockchainThunks.ts index 49efb5b6968..cd9e3eb6cd3 100644 --- a/suite-common/wallet-core/src/blockchain/blockchainThunks.ts +++ b/suite-common/wallet-core/src/blockchain/blockchainThunks.ts @@ -3,6 +3,7 @@ import { notificationsActions } from '@suite-common/toast-notifications'; import { NetworkSymbol, externalBackendTypeNetworks, + getNetworkFeatures, getNetworkOptional, isNetworkSymbol, isTrezorInfraBasedNetwork, @@ -127,13 +128,11 @@ export const updateFeeInfoThunk = createThunk( let newFeeInfo; if (network.networkType === 'ethereum') { - // NOTE: ethereum smart fees are not implemented properly in @trezor/connect Issue: https://github.com/trezor/trezor-suite/issues/5340 - // create raw call to @trezor/blockchain-link, receive data and create FeeLevel.normal from it - const result = await TrezorConnect.blockchainEstimateFee({ coin: network.symbol, request: { - blocks: [2], + blocks: [1], + feeLevels: 'smart', specific: { from: '0x0000000000000000000000000000000000000000', to: '0x0000000000000000000000000000000000000000', @@ -146,7 +145,7 @@ export const updateFeeInfoThunk = createThunk( levels: result.payload.levels.map(l => ({ ...l, blocks: -1, // NOTE: @trezor/connect returns -1 for ethereum default - label: 'normal' as const, + label: l?.label || ('normal' as const), })), }; } diff --git a/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts b/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts index 6acc6b9aa7d..5ed31a27509 100644 --- a/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts +++ b/suite-common/wallet-core/src/send/sendFormEthereumThunks.ts @@ -1,4 +1,4 @@ -import { fromWei, toWei } from 'web3-utils'; +import { toWei } from 'web3-utils'; import { createThunk } from '@suite-common/redux-utils'; import { notificationsActions } from '@suite-common/toast-notifications'; @@ -11,16 +11,10 @@ import { import { Account, AddressDisplayOptions, - ExternalOutput, PrecomposedLevels, - PrecomposedTransaction, RbfTransactionParams, } from '@suite-common/wallet-types'; import { - amountToSmallestUnit, - calculateEthFee, - calculateMax, - calculateTotal, formatAmount, getAccountIdentity, getEthereumEstimateFeeParams, @@ -30,10 +24,11 @@ import { isPending, prepareEthereumTransaction, } from '@suite-common/wallet-utils'; -import TrezorConnect, { FeeLevel, TokenInfo } from '@trezor/connect'; +import TrezorConnect, { FeeLevel } from '@trezor/connect'; import { BigNumber } from '@trezor/utils/src/bigNumber'; import { SEND_MODULE_PREFIX } from './sendFormConstants'; +import { calculateEffectiveGasPrice, calculateEvmTxWithFees } from './sendFormEthereumUtils'; import { ComposeFeeLevelsError, ComposeTransactionThunkArguments, @@ -41,95 +36,6 @@ import { SignTransactionThunkArguments, } from './sendFormTypes'; import { selectTransactions } from '../transactions/transactionsReducer'; - -const calculate = ( - availableBalance: string, - output: ExternalOutput, - feeLevel: FeeLevel, - token?: TokenInfo, -): PrecomposedTransaction => { - let amount: string; - let max: string | undefined; - const feeInGwei = calculateEthFee(toWei(feeLevel.feePerUnit, 'gwei'), feeLevel.feeLimit || '0'); - - const availableTokenBalance = token - ? amountToSmallestUnit(token.balance!, token.decimals) - : undefined; - if (output.type === 'send-max' || output.type === 'send-max-noaddress') { - max = availableTokenBalance || calculateMax(availableBalance, feeInGwei); - amount = max; - } else { - amount = output.amount; - } - - // total ETH spent (amount + fee), in ERC20 only fee - const totalSpent = new BigNumber(calculateTotal(token ? '0' : amount, feeInGwei)); - - if (totalSpent.isGreaterThan(availableBalance)) { - if (token) { - return { - type: 'error', - error: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', - errorMessage: { - id: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', - values: { - feeAmount: fromWei(feeInGwei, 'ether').toString(), - }, - }, - } as const; - } - - return { - type: 'error', - error: 'AMOUNT_IS_NOT_ENOUGH', - errorMessage: { id: 'AMOUNT_IS_NOT_ENOUGH' }, - } as const; - } - - // validate if token balance is not 0 or lower than amount - if ( - availableTokenBalance && - (availableTokenBalance === '0' || new BigNumber(amount).gt(availableTokenBalance)) - ) { - return { - type: 'error', - error: 'AMOUNT_IS_NOT_ENOUGH', - errorMessage: { id: 'AMOUNT_IS_NOT_ENOUGH' }, - } as const; - } - - const payloadData = { - type: 'nonfinal' as const, - totalSpent: token ? amount : totalSpent.toString(), - max, - fee: feeInGwei, - feePerByte: feeLevel.feePerUnit, - feeLimit: feeLevel.feeLimit, - token, - bytes: 0, // TODO: calculate - inputs: [], - }; - - if (output.type === 'send-max' || output.type === 'payment') { - return { - ...payloadData, - type: 'final', - // compatibility with BTC PrecomposedTransaction from @trezor/connect - inputs: [], - outputsPermutation: [0], - outputs: [ - { - address: output.address, - amount, - script_type: 'PAYTOADDRESS', - }, - ], - }; - } - - return payloadData; -}; - export const composeEthereumTransactionFeeLevelsThunk = createThunk< PrecomposedLevels, ComposeTransactionThunkArguments, @@ -164,6 +70,7 @@ export const composeEthereumTransactionFeeLevelsThunk = createThunk< coin: account.symbol, identity: getAccountIdentity(account), request: { + feeLevels: 'smart', blocks: [2], specific: { from: account.descriptor, @@ -212,10 +119,28 @@ export const composeEthereumTransactionFeeLevelsThunk = createThunk< } // in case when selectedFee is set to 'custom' construct this FeeLevel from values if (formState.selectedFee === 'custom') { + const { customMaxPriorityFeePerGas, customMaxBaseFeePerGas, feePerUnit, feeLimit } = + formState; + + const customMaxPriorityFeePerGasWei = + customMaxPriorityFeePerGas && toWei(Number(customMaxPriorityFeePerGas), 'gwei'); + + const customMaxBaseFeePerGasWei = + customMaxBaseFeePerGas && toWei(Number(customMaxBaseFeePerGas), 'gwei'); + + const effectiveGasPrice = calculateEffectiveGasPrice({ + maxFeePerGasGwei: customMaxBaseFeePerGas || '0', + maxPriorityFeePerGasGwei: customMaxPriorityFeePerGas || '0', + }); + + //suspect predefinedLevels.push({ label: 'custom', - feePerUnit: formState.feePerUnit, - feeLimit: formState.feeLimit, + feePerUnit, + feeLimit, + effectiveGasPrice, + maxPriorityFeePerGas: customMaxPriorityFeePerGasWei, + maxFeePerGas: customMaxBaseFeePerGasWei, blocks: -1, }); } @@ -223,7 +148,7 @@ export const composeEthereumTransactionFeeLevelsThunk = createThunk< // wrap response into PrecomposedLevels object where key is a FeeLevel label const resultLevels: PrecomposedLevels = {}; const response = predefinedLevels.map(level => - calculate(availableBalance, output, level, tokenInfo), + calculateEvmTxWithFees({ availableBalance, output, feeLevel: level, token: tokenInfo }), ); response.forEach((tx, index) => { const feeLabel = predefinedLevels[index].label as FeeLevel['label']; @@ -331,6 +256,8 @@ export const signEthereumSendFormTransactionThunk = createThunk< amount: formState.outputs[0].amount, data: formState.ethereumDataHex, gasLimit: precomposedTransaction.feeLimit || '', + maxFeePerGas: precomposedTransaction.maxFeePerGas ?? undefined, + maxPriorityFeePerGas: precomposedTransaction.maxPriorityFeePerGas ?? undefined, gasPrice: precomposedTransaction.feePerByte, nonce, }); diff --git a/suite-common/wallet-core/src/send/sendFormEthereumUtils.ts b/suite-common/wallet-core/src/send/sendFormEthereumUtils.ts new file mode 100644 index 00000000000..44c0b2b6627 --- /dev/null +++ b/suite-common/wallet-core/src/send/sendFormEthereumUtils.ts @@ -0,0 +1,190 @@ +import { fromWei, toWei } from 'web3-utils'; + +import { ExternalOutput, PrecomposedTransaction } from '@suite-common/wallet-types'; +import { + amountToSmallestUnit, + calculateMax, + calculateMaxEthFee, + calculateTotal, +} from '@suite-common/wallet-utils'; +import { FeeLevel, TokenInfo } from '@trezor/connect'; +import { BigNumber } from '@trezor/utils'; + +export type CalculateEvmTxWithFeesProps = { + availableBalance: string; + output: ExternalOutput; + feeLevel: FeeLevel; + token?: TokenInfo; +}; + +/** + * Calculate the effective gas price for eip1559 type transactions from maxPriorityFeePerGas and maxFeePerGas + * @param maxPriorityFeePerGasGwei - maxPriorityFeePerGas in gwei + * @param maxFeePerGasGwei - maxFeePerGas in gwei + * @returns effective gas price in wei + */ + +type CalculateEffectiveGasPriceProps = { + maxFeePerGasGwei?: string; + maxPriorityFeePerGasGwei?: string; +}; + +export const calculateEffectiveGasPrice = ({ + maxFeePerGasGwei, + maxPriorityFeePerGasGwei, +}: CalculateEffectiveGasPriceProps) => { + if (!maxFeePerGasGwei) { + return undefined; + } + if (!maxPriorityFeePerGasGwei) { + maxPriorityFeePerGasGwei = '0'; + } + const baseFee = new BigNumber(toWei(maxFeePerGasGwei, 'gwei')); + const priorityFee = new BigNumber(toWei(maxPriorityFeePerGasGwei, 'gwei')); + + return baseFee.plus(priorityFee).toString(); +}; + +export const calculateEffectiveGasPriceGwei = ({ + maxFeePerGasGwei, + maxPriorityFeePerGasGwei, +}: CalculateEffectiveGasPriceProps) => { + if (!maxFeePerGasGwei) { + return undefined; + } + if (!maxPriorityFeePerGasGwei) { + maxPriorityFeePerGasGwei = '0'; + } + + return new BigNumber(maxFeePerGasGwei).plus(maxPriorityFeePerGasGwei).toString(); +}; + +/** + * Calculate the base fee from effective gas price for eip1559 type transactions + * @param effectiveGasPriceWei - effective gas price in wei + * @param maxPriorityFeePerGasWei - maxPriorityFeePerGas in wei + * @returns base fee in wei + */ + +type CalculateBaseFeeFromEffectiveGasPriceProps = { + effectiveGasPriceWei: string; + maxPriorityFeePerGasWei?: string; +}; + +export const calculateBaseFeeFromEffectiveGasPrice = ({ + effectiveGasPriceWei, + maxPriorityFeePerGasWei, +}: CalculateBaseFeeFromEffectiveGasPriceProps) => { + if (!effectiveGasPriceWei) { + return undefined; + } + if (!maxPriorityFeePerGasWei) { + maxPriorityFeePerGasWei = '0'; + } + const effectiveInWei = BigNumber(effectiveGasPriceWei); + const priorityFee = BigNumber(maxPriorityFeePerGasWei); + + return effectiveInWei.minus(priorityFee).toString(); +}; + +export const calculateEvmTxWithFees = ({ + availableBalance, + output, + feeLevel, + token, +}: CalculateEvmTxWithFeesProps): PrecomposedTransaction => { + let amount: string; + let max: string | undefined; + + const eip1559 = feeLevel.maxPriorityFeePerGas + ? { + maxPriorityFeePerGas: feeLevel.maxPriorityFeePerGas, + maxFeePerGas: feeLevel.maxFeePerGas, + effectiveGasPrice: feeLevel.effectiveGasPrice, + } + : undefined; + + const feeInWei = eip1559 + ? calculateMaxEthFee(eip1559.effectiveGasPrice, feeLevel.feeLimit) + : calculateMaxEthFee(feeLevel.feePerUnit, feeLevel.feeLimit); + + const availableTokenBalance = token + ? amountToSmallestUnit(token.balance!, token.decimals) + : undefined; + + if (output.type === 'send-max' || output.type === 'send-max-noaddress') { + max = availableTokenBalance || calculateMax(availableBalance, feeInWei); + amount = max; + } else { + amount = output.amount; + } + + // total ETH spent (amount + fee), in ERC20 only fee + const totalSpent = new BigNumber(calculateTotal(token ? '0' : amount, feeInWei)); + + if (totalSpent.isGreaterThan(availableBalance)) { + if (token) { + return { + type: 'error', + error: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + errorMessage: { + id: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + values: { + feeAmount: fromWei(feeInWei, 'ether').toString(), + }, + }, + } as const; + } + + return { + type: 'error', + error: 'AMOUNT_IS_NOT_ENOUGH', + errorMessage: { id: 'AMOUNT_IS_NOT_ENOUGH' }, + } as const; + } + + // validate if token balance is not 0 or lower than amount + if ( + availableTokenBalance && + (availableTokenBalance === '0' || new BigNumber(amount).gt(availableTokenBalance)) + ) { + return { + type: 'error', + error: 'AMOUNT_IS_NOT_ENOUGH', + errorMessage: { id: 'AMOUNT_IS_NOT_ENOUGH' }, + } as const; + } + + const payloadData = { + type: 'nonfinal' as const, + totalSpent: token ? amount : totalSpent.toString(), + max, + fee: feeInWei, + maxFeePerGas: feeLevel.effectiveGasPrice, + maxPriorityFeePerGas: feeLevel.maxPriorityFeePerGas, + feePerByte: feeLevel.feePerUnit, + feeLimit: feeLevel.feeLimit, + token, + bytes: 0, // TODO: calculate + inputs: [], + }; + + if (output.type === 'send-max' || output.type === 'payment') { + return { + ...payloadData, + type: 'final', + // compatibility with BTC PrecomposedTransaction from @trezor/connect + inputs: [], + outputsPermutation: [0], + outputs: [ + { + address: output.address, + amount, + script_type: 'PAYTOADDRESS', + }, + ], + }; + } + + return payloadData; +}; diff --git a/suite-common/wallet-core/tests/send/sendFormEthereumUtils.fixtures.ts b/suite-common/wallet-core/tests/send/sendFormEthereumUtils.fixtures.ts new file mode 100644 index 00000000000..2ae35d375f3 --- /dev/null +++ b/suite-common/wallet-core/tests/send/sendFormEthereumUtils.fixtures.ts @@ -0,0 +1,267 @@ +export const calculateEvmTxWithFees = [ + { + description: 'Legacy fee, no token, tx should pass', + input: { + availableBalance: '1000000000000000000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { label: 'normal' as const, feePerUnit: '10', feeLimit: '21000', blocks: -1 }, + token: undefined, + }, + result: { + max: undefined, + bytes: 0, + fee: '210000000000000', + feeLimit: '21000', + feePerByte: '10', + inputs: [], + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + outputs: [ + { + address: 'A', + amount: '1', + script_type: 'PAYTOADDRESS', + }, + ], + outputsPermutation: [0], + token: undefined, + totalSpent: '210000000000001', + type: 'final', + }, + }, + + { + description: 'Legacy fee, no token, balance is not enough', + input: { + availableBalance: '1000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { label: 'normal' as const, feePerUnit: '10', feeLimit: '21000', blocks: -1 }, + token: undefined, + }, + result: { + type: 'error', + error: 'AMOUNT_IS_NOT_ENOUGH', + errorMessage: { + id: 'AMOUNT_IS_NOT_ENOUGH', + }, + }, + }, + { + description: 'Legacy fee, token transfer, tx should pass', + input: { + availableBalance: '1000000000000000000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { label: 'normal' as const, feePerUnit: '10', feeLimit: '21000', blocks: -1 }, + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '100', + }, + }, + result: { + max: undefined, + bytes: 0, + fee: '210000000000000', + feeLimit: '21000', + feePerByte: '10', + inputs: [], + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + outputs: [ + { + address: 'A', + amount: '1', + script_type: 'PAYTOADDRESS', + }, + ], + outputsPermutation: [0], + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '100', + }, + totalSpent: '1', + type: 'final', + }, + }, + + { + description: 'Legacy fee, token transfer, balance is not enough', + input: { + availableBalance: '100000', + output: { type: 'payment' as const, address: 'A', amount: '101' }, + feeLevel: { label: 'normal' as const, feePerUnit: '10', feeLimit: '21000', blocks: -1 }, + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '1', + }, + }, + result: { + type: 'error', + error: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + errorMessage: { + id: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + values: { feeAmount: '0.00021' }, + }, + }, + }, + { + description: 'Eip1559 fee, no token, tx should pass', + input: { + availableBalance: '1000000000000000000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { + label: 'low' as const, + feeLimit: '21000', + feePerUnit: '0', + maxFeePerGas: '1191416838', + effectiveGasPrice: '1191416838', + maxPriorityFeePerGas: '7946902', + blocks: -1, + }, + token: undefined, + }, + result: { + max: undefined, + bytes: 0, + fee: '25019753598000', + feeLimit: '21000', + feePerByte: '0', + inputs: [], + maxFeePerGas: '1191416838', + maxPriorityFeePerGas: '7946902', + outputs: [ + { + address: 'A', + amount: '1', + script_type: 'PAYTOADDRESS', + }, + ], + outputsPermutation: [0], + token: undefined, + totalSpent: '25019753598001', + type: 'final', + }, + }, + + { + description: 'Eip1559 fee, no token, balance is not enough', + input: { + availableBalance: '1000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { + label: 'low' as const, + feeLimit: '21000', + feePerUnit: '0', + maxFeePerGas: '1191416838', + effectiveGasPrice: '1191416838', + maxPriorityFeePerGas: '7946902', + blocks: -1, + }, + token: undefined, + }, + result: { + type: 'error', + error: 'AMOUNT_IS_NOT_ENOUGH', + errorMessage: { + id: 'AMOUNT_IS_NOT_ENOUGH', + }, + }, + }, + + { + description: 'Eip1559 fee, token transfer, should pass', + input: { + availableBalance: '1000000000000000000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { + label: 'low' as const, + feeLimit: '21000', + feePerUnit: '0', + maxFeePerGas: '1191416838', + effectiveGasPrice: '1191416838', + maxPriorityFeePerGas: '7946902', + blocks: -1, + }, + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '100', + }, + }, + result: { + max: undefined, + bytes: 0, + fee: '25019753598000', + feeLimit: '21000', + feePerByte: '0', + inputs: [], + maxFeePerGas: '1191416838', + maxPriorityFeePerGas: '7946902', + outputs: [ + { + address: 'A', + amount: '1', + script_type: 'PAYTOADDRESS', + }, + ], + outputsPermutation: [0], + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '100', + }, + totalSpent: '1', + type: 'final', + }, + }, + + { + description: 'Eip1559 fee, token transfer, balance is not enough', + input: { + availableBalance: '1000', + output: { type: 'payment' as const, address: 'A', amount: '1' }, + feeLevel: { + label: 'low' as const, + feeLimit: '21000', + feePerUnit: '0', + maxFeePerGas: '1191416838', + effectiveGasPrice: '1191416838', + maxPriorityFeePerGas: '7946902', + blocks: -1, + }, + token: { + type: 'ERC20', + standard: 'ERC20', + contract: '0x123', + symbol: 'test', + decimals: 18, + balance: '100', + }, + }, + result: { + error: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + errorMessage: { + id: 'AMOUNT_NOT_ENOUGH_CURRENCY_FEE_WITH_ETH_AMOUNT', + values: { feeAmount: '0.000025019753598' }, + }, + type: 'error', + }, + }, +]; diff --git a/suite-common/wallet-core/tests/send/sendFormEthereumUtils.test.ts b/suite-common/wallet-core/tests/send/sendFormEthereumUtils.test.ts new file mode 100644 index 00000000000..8abf46e8e3f --- /dev/null +++ b/suite-common/wallet-core/tests/send/sendFormEthereumUtils.test.ts @@ -0,0 +1,12 @@ +import * as fixtures from './sendFormEthereumUtils.fixtures'; +import { calculateEvmTxWithFees } from '../../src/send/sendFormEthereumUtils'; + +describe(calculateEvmTxWithFees.name, () => { + fixtures.calculateEvmTxWithFees.forEach(f => { + it(`${f.description}`, () => { + const result = calculateEvmTxWithFees(f.input); + + expect(result).toEqual(f.result); + }); + }); +}); diff --git a/suite-common/wallet-types/src/sendForm.ts b/suite-common/wallet-types/src/sendForm.ts index c351911eeb4..e790a9dd9f2 100644 --- a/suite-common/wallet-types/src/sendForm.ts +++ b/suite-common/wallet-types/src/sendForm.ts @@ -17,6 +17,9 @@ export interface FormState { setMaxOutputId?: number; selectedFee?: FeeLevel['label']; feePerUnit: string; // bitcoin/ethereum/ripple custom fee field (satB/gasPrice/drops) + maxPriorityFeePerGas?: string; // ethereum eip1559 only + maxFeePerGas?: string; // ethereum eip1559 only + baseFeePerGas?: string; // ethereum eip1559 only feeLimit: string; // ethereum only (gasLimit) estimatedFeeLimit?: string; // ethereum only (gasLimit) @@ -40,6 +43,9 @@ export interface FormState { isCoinControlEnabled: boolean; hasCoinControlBeenOpened: boolean; anonymityWarningChecked?: boolean; + customMaxBaseFeePerGas?: string; // ethereum eip1559 only custom + customMaxPriorityFeePerGas?: string; // ethereum eip1559 only custom + effectiveGasPrice?: string; // ethereum eip1559 only selectedUtxos: AccountUtxo[]; utxoSorting?: UtxoSorting; isTrading?: boolean; diff --git a/suite-common/wallet-types/src/transaction.ts b/suite-common/wallet-types/src/transaction.ts index 315f85c9796..fafd1f03519 100644 --- a/suite-common/wallet-types/src/transaction.ts +++ b/suite-common/wallet-types/src/transaction.ts @@ -77,8 +77,10 @@ export type EthTransactionData = { amount: string; data?: string; gasLimit: string; - gasPrice: string; nonce: string; + gasPrice?: string; // this field is not used for EIP1559 transactions (See EthereumTransaction and EthereumTransactionEIP1559 types in connect) + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; }; export type ExternalOutput = Exclude; @@ -107,6 +109,9 @@ type PrecomposedTransactionBase = PrecomposedTransactionConnectResponseFinal & { max?: string; feeLimit?: string; estimatedFeeLimit?: string; + maxFeePerGas?: string; + maxPriorityFeePerGas?: string; + effectiveGasPrice?: string; token?: TokenInfo; isTokenKnown?: boolean; createdTimestamp?: number; diff --git a/suite-common/wallet-utils/src/__fixtures__/sendFormUtils.ts b/suite-common/wallet-utils/src/__fixtures__/sendFormUtils.ts index 492936e887f..96bd906aaa9 100644 --- a/suite-common/wallet-utils/src/__fixtures__/sendFormUtils.ts +++ b/suite-common/wallet-utils/src/__fixtures__/sendFormUtils.ts @@ -50,6 +50,66 @@ export const prepareEthereumTransaction = [ data: '0xa9059cbb000000000000000000000000A6ABB480640d6D27D2FB314196D94463ceDcB31e0000000000000000000000000000000000000000000000000011c37937e08000', }, }, + + { + description: 'regular with eip1559 fees', + txInfo: { + to: '0x1f815D67006163E502b8eD4947C91ad0A62De24e', + amount: '1', + chainId: 1, + nonce: '2', + gasLimit: '21000', + gasPrice: '1', + data: 'deadbeef', + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + result: { + to: '0x1f815D67006163E502b8eD4947C91ad0A62De24e', + value: '0xde0b6b3a7640000', + chainId: 1, + nonce: '0x2', + gasLimit: '0x5208', + gasPrice: undefined, + data: '0xdeadbeef', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, + }, + + { + description: 'erc20 with eip1559 fees', + txInfo: { + token: { + type: 'ERC20', + standard: 'ERC20', + symbol: 'gnt', + decimals: 18, + contract: '0xa74476443119A942dE498590Fe1f2454d7D4aC0d', + name: 'GNT', + }, + to: '0xA6ABB480640d6D27D2FB314196D94463ceDcB31e', + amount: '0.005', + chainId: 1, + nonce: '11', + gasLimit: '200000', + gasPrice: '5', + data: 'deadbeef-not-used', + maxFeePerGas: '1', + maxPriorityFeePerGas: '1', + }, + result: { + to: '0xa74476443119A942dE498590Fe1f2454d7D4aC0d', + value: '0x00', + chainId: 1, + nonce: '0xb', + gasLimit: '0x30d40', + gasPrice: undefined, + data: '0xa9059cbb000000000000000000000000A6ABB480640d6D27D2FB314196D94463ceDcB31e0000000000000000000000000000000000000000000000000011c37937e08000', + maxFeePerGas: '0x1', + maxPriorityFeePerGas: '0x1', + }, + }, ]; export const restoreOrigOutputsOrder = [ diff --git a/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts b/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts index 487a444a6e4..4fa770cbc5b 100644 --- a/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/accountUtils.test.ts @@ -236,6 +236,7 @@ describe('account utils', () => { 'coin-definitions', 'nft-definitions', 'staking', + 'eip1559', ]); expect(getNetworkAccountFeatures(coinjoinAcc)).toEqual(['rbf', 'amount-unit']); // when account does not have features defined, take them from root network object diff --git a/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts b/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts index 40088babd61..52b460630e1 100644 --- a/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts +++ b/suite-common/wallet-utils/src/__tests__/sendFormUtils.test.ts @@ -4,8 +4,8 @@ import { networks } from '@suite-common/wallet-config'; import * as fixtures from '../__fixtures__/sendFormUtils'; import { getUtxoOutpoint } from '../accountUtils'; import { - calculateEthFee, calculateMax, + calculateMaxEthFee, calculateTotal, findComposeErrors, getBitcoinComposeOutputs, @@ -358,18 +358,18 @@ describe('sendForm utils', () => { }); }); - it('calculateEthFee', () => { - expect(calculateEthFee()).toEqual('0'); - expect(calculateEthFee('', '')).toEqual('0'); - expect(calculateEthFee('1', '')).toEqual('0'); - expect(calculateEthFee('0', '1')).toEqual('0'); + it('calculateMaxEthFee', () => { + expect(calculateMaxEthFee()).toEqual('0'); + expect(calculateMaxEthFee('', '')).toEqual('0'); + expect(calculateMaxEthFee('1', '')).toEqual('0'); + expect(calculateMaxEthFee('0', '1')).toEqual('0'); // @ts-expect-error invalid params - expect(calculateEthFee({}, {})).toEqual('0'); + expect(calculateMaxEthFee({}, {})).toEqual('0'); // @ts-expect-error invalid params - expect(calculateEthFee(() => {}, {})).toEqual('0'); + expect(calculateMaxEthFee(() => {}, {})).toEqual('0'); // @ts-expect-error invalid params - expect(calculateEthFee(null, true)).toEqual('0'); - expect(calculateEthFee('1', '2')).toEqual('2'); + expect(calculateMaxEthFee(null, true)).toEqual('0'); + expect(calculateMaxEthFee('1', '2')).toEqual('2'); }); it('getExcludedUtxos', () => { diff --git a/suite-common/wallet-utils/src/sendFormUtils.ts b/suite-common/wallet-utils/src/sendFormUtils.ts index 37f451bc95e..af3cb4625a6 100644 --- a/suite-common/wallet-utils/src/sendFormUtils.ts +++ b/suite-common/wallet-utils/src/sendFormUtils.ts @@ -28,11 +28,18 @@ import type { FormState, GeneralPrecomposedTransactionFinal, Output, + PrecomposedTransactionFinal, RbfTransactionParams, SendFormDraftKey, TokenAddress, } from '@suite-common/wallet-types'; -import { ComposeOutput, EthereumTransaction, PROTO, TokenInfo } from '@trezor/connect'; +import { + ComposeOutput, + EthereumTransaction, + EthereumTransactionEIP1559, + PROTO, + TokenInfo, +} from '@trezor/connect'; import { BigNumber } from '@trezor/utils/src/bigNumber'; import { amountToSmallestUnit, getUtxoOutpoint, networkAmountToSmallestUnit } from './accountUtils'; @@ -81,7 +88,7 @@ export const calculateMax = (availableBalance: string, fee: string): string => { * @param {string} [gasLimit] - The gas limit. * @returns {string} The calculated fee in wei, or '0' if inputs are invalid. */ -export const calculateEthFee = (gasPrice?: string, gasLimit?: string): string => { +export const calculateMaxEthFee = (gasPrice?: string, gasLimit?: string): string => { if (!gasPrice || !gasLimit) { return '0'; } @@ -139,15 +146,35 @@ export const getEthereumEstimateFeeParams = ( }; }; -export const prepareEthereumTransaction = (txInfo: EthTransactionData) => { - const result: EthereumTransaction = { - to: txInfo.to, - value: getSerializedAmount(txInfo.amount), - chainId: txInfo.chainId, - nonce: numberToHex(txInfo.nonce), - gasLimit: numberToHex(txInfo.gasLimit), - gasPrice: numberToHex(toWei(txInfo.gasPrice, 'gwei')), - }; +export const prepareEthereumTransaction = ( + txInfo: EthTransactionData, +): EthereumTransaction | EthereumTransactionEIP1559 => { + let result: EthereumTransaction | EthereumTransactionEIP1559; + if (txInfo.maxFeePerGas && txInfo.maxPriorityFeePerGas) { + result = { + to: txInfo.to, + value: getSerializedAmount(txInfo.amount), + chainId: txInfo.chainId, + nonce: numberToHex(txInfo.nonce), + gasLimit: numberToHex(txInfo.gasLimit), + gasPrice: undefined, //Not sure if this is a good way to handle this + maxFeePerGas: numberToHex(txInfo.maxFeePerGas), + maxPriorityFeePerGas: numberToHex(txInfo.maxPriorityFeePerGas), + } as EthereumTransactionEIP1559; + } else if (txInfo.gasPrice) { + result = { + to: txInfo.to, + value: getSerializedAmount(txInfo.amount), + chainId: txInfo.chainId, + nonce: numberToHex(txInfo.nonce), + gasPrice: numberToHex(toWei(txInfo.gasPrice, 'gwei')), //Not sure if this is a good way to handle this + gasLimit: numberToHex(txInfo.gasLimit), + maxFeePerGas: undefined, + maxPriorityFeePerGas: undefined, + } as EthereumTransaction; + } else { + throw new Error('No gas price or maxFeePerGas and maxPriorityFeePerGas provided'); + } if (!txInfo.token && txInfo.data) { result.data = sanitizeHex(txInfo.data); @@ -179,9 +206,8 @@ const getFeeLevels = ({ feeInfo, networkType }: GetFeeInfoProps) => { }); if (networkType === 'ethereum') { - // convert wei to gwei return levels.map(level => { - const gwei = new BigNumber(fromWei(level.feePerUnit, 'gwei')); + const gwei = new BigNumber(fromWei(level.feePerUnit, 'gwei')); // convert wei to gwei // blockbook/geth may return 0 in feePerUnit. if this happens set at least minFee const feePerUnit = level.label !== 'custom' && gwei.lt(feeInfo.minFee) @@ -191,7 +217,6 @@ const getFeeLevels = ({ feeInfo, networkType }: GetFeeInfoProps) => { return { ...level, feePerUnit, - feeLimit: level.feeLimit, }; }); } @@ -228,8 +253,19 @@ const mapNetworkTypeToFeeUnits: Record = { export const getFeeUnits = (networkType: NetworkType) => mapNetworkTypeToFeeUnits[networkType]; -export const getFee = (networkType: NetworkType, tx: GeneralPrecomposedTransactionFinal) => { - if (networkType === 'solana') return tx.fee; +type GetFeeProps = { + account: Account; + tx: GeneralPrecomposedTransactionFinal; + baseFee?: string; + shouldUsePriorityFees?: boolean; +}; + +export const getFee = ({ account, tx, shouldUsePriorityFees }: GetFeeProps) => { + if (account.networkType === 'solana') return tx.fee; + + if (account.networkType === 'ethereum' && shouldUsePriorityFees) { + return fromWei((tx as PrecomposedTransactionFinal).maxFeePerGas || '0', 'gwei'); + } return tx.feePerByte; };