From 36533a6618e23cf1563935ab1bdf497f40cf9ec8 Mon Sep 17 00:00:00 2001 From: Albina Nikiforova Date: Fri, 21 Feb 2025 13:57:46 +0100 Subject: [PATCH] refactor(connect): separate bitcoin and misc fee levels --- .../blockchain-link-types/src/responses.ts | 2 + .../api/bitcoin/{Fees.ts => BitcoinFees.ts} | 53 ++------- .../src/api/bitcoin/TransactionComposer.ts | 12 +- .../{Fees.test.ts => BitcoinFees.test.ts} | 14 +-- packages/connect/src/api/bitcoin/index.ts | 2 +- .../connect/src/api/blockchainEstimateFee.ts | 17 ++- packages/connect/src/api/common/MiscFees.ts | 68 ++++++++++++ .../connect/src/api/ethereum/EthereumFees.ts | 105 ++++++++++++++++++ .../src/types/api/__tests__/blockchain.ts | 2 +- packages/connect/src/types/fees.ts | 14 +++ 10 files changed, 229 insertions(+), 60 deletions(-) rename packages/connect/src/api/bitcoin/{Fees.ts => BitcoinFees.ts} (71%) rename packages/connect/src/api/bitcoin/__tests__/{Fees.test.ts => BitcoinFees.test.ts} (91%) create mode 100644 packages/connect/src/api/common/MiscFees.ts create mode 100644 packages/connect/src/api/ethereum/EthereumFees.ts diff --git a/packages/blockchain-link-types/src/responses.ts b/packages/blockchain-link-types/src/responses.ts index c440fdd60c7..a28c3afdba3 100644 --- a/packages/blockchain-link-types/src/responses.ts +++ b/packages/blockchain-link-types/src/responses.ts @@ -1,4 +1,5 @@ import type { Block, MempoolTransactionNotification } from './blockbook'; +import { Eip1559Fees } from './blockbook-api'; import type { AccountBalanceHistory, AccountInfo, @@ -98,6 +99,7 @@ export interface EstimateFee { feePerUnit: string; feePerTx?: string; feeLimit?: string; + eip1559?: Eip1559Fees; }[]; } diff --git a/packages/connect/src/api/bitcoin/Fees.ts b/packages/connect/src/api/bitcoin/BitcoinFees.ts similarity index 71% rename from packages/connect/src/api/bitcoin/Fees.ts rename to packages/connect/src/api/bitcoin/BitcoinFees.ts index 1f41d03f33d..5f9796aed41 100644 --- a/packages/connect/src/api/bitcoin/Fees.ts +++ b/packages/connect/src/api/bitcoin/BitcoinFees.ts @@ -3,8 +3,8 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import { Blockchain } from '../../backend/BlockchainLink'; -import type { CoinInfo, FeeLevel } from '../../types'; - +import type { BitcoinNetworkInfo, FeeLevel } from '../../types'; +import { findBlocksForFee } from '../common/MiscFees'; type Blocks = Array; const convertFeeRate = (fee: string, minFee: number) => { @@ -56,56 +56,20 @@ const findLowest = (blocks: Blocks) => .reverse() .find(item => typeof item === 'string'); -const findBlocksForFee = (feePerUnit: string, blocks: Blocks) => { - const bn = new BigNumber(feePerUnit); - // find first occurrence of value lower or equal than requested - const lower = blocks.find(b => typeof b === 'string' && bn.gte(b)); - if (!lower) return -1; - - // if not found get latest know value - return blocks.indexOf(lower); -}; - -export class FeeLevels { - coinInfo: CoinInfo; +export class BitcoinFeeLevels { + coinInfo: BitcoinNetworkInfo; levels: FeeLevel[]; longTermFeeRate?: string; // long term fee rate is used by @trezor/utxo-lib composeTx module blocks: Blocks = []; - constructor(coinInfo: CoinInfo) { + constructor(coinInfo: BitcoinNetworkInfo) { this.coinInfo = coinInfo; this.levels = coinInfo.defaultFees; } - async loadMisc(blockchain: Blockchain) { - try { - const [response] = await blockchain.estimateFee({ blocks: [1] }); - // misc coins should have only one FeeLevel (normal) - this.levels[0] = { - ...this.levels[0], - ...response, - // validate `feePerUnit` from the backend - // should be lower than `coinInfo.maxFee` and higher than `coinInfo.minFee` - // xrp sends values from 1 to very high number occasionally - // see: https://github.com/trezor/trezor-suite/blob/develop/packages/blockchain-link/src/workers/ripple/index.ts#L316 - feePerUnit: Math.min( - this.coinInfo.maxFee, - Math.max(this.coinInfo.minFee, parseInt(response.feePerUnit, 10)), - ).toString(), - }; - } catch { - // silent - } - - return this.levels; - } - async load(blockchain: Blockchain) { - if (this.coinInfo.type !== 'bitcoin') return this.loadMisc(blockchain); - // only for bitcoin-like - let blocks = fillGap(0, 1, 10); if (this.levels.length > 1) { // multiple levels @@ -135,7 +99,10 @@ export class FeeLevels { try { const response = await blockchain.estimateFee({ blocks }); response.forEach(({ feePerUnit }, index) => { - this.blocks[blocks[index]] = convertFeeRate(feePerUnit, this.coinInfo.minFee); + this.blocks[blocks[index]] = convertFeeRate( + feePerUnit || '0', + this.coinInfo.minFee, + ); }); this.levels.forEach(level => { @@ -154,7 +121,7 @@ export class FeeLevels { return this.levels; } - updateCustomFee(feePerUnit: string) { + updateBitcoinCustomFee(feePerUnit: string) { // remove "custom" level from list this.levels = this.levels.filter(l => l.label !== 'custom'); // recreate "custom" level diff --git a/packages/connect/src/api/bitcoin/TransactionComposer.ts b/packages/connect/src/api/bitcoin/TransactionComposer.ts index eed454b12d4..09db0664a87 100644 --- a/packages/connect/src/api/bitcoin/TransactionComposer.ts +++ b/packages/connect/src/api/bitcoin/TransactionComposer.ts @@ -3,7 +3,7 @@ import { BigNumber } from '@trezor/utils/src/bigNumber'; import { ComposeOutput, TransactionInputOutputSortingStrategy, composeTx } from '@trezor/utxo-lib'; -import { FeeLevels } from './Fees'; +import { BitcoinFeeLevels } from './BitcoinFees'; import { Blockchain } from '../../backend/BlockchainLink'; import type { BitcoinNetworkInfo, DiscoveryAccount, SelectFeeLevel } from '../../types'; import type { @@ -36,7 +36,7 @@ export class TransactionComposer { sortingStrategy: TransactionInputOutputSortingStrategy; - feeLevels: FeeLevels; + feeLevels: BitcoinFeeLevels; composed: { [key: string]: ComposeResult } = {}; @@ -47,7 +47,7 @@ export class TransactionComposer { this.blockHeight = 0; this.baseFee = options.baseFee || 0; this.sortingStrategy = options.sortingStrategy; - this.feeLevels = new FeeLevels(options.coinInfo); + this.feeLevels = new BitcoinFeeLevels(options.coinInfo); // map to @trezor/utxo-lib/compose format const { addresses } = options.account; @@ -101,7 +101,7 @@ export class TransactionComposer { const tx = this.compose(lastFee.toString()); if (tx.type === 'final') { - this.feeLevels.updateCustomFee(lastFee.toString()); + this.feeLevels.updateBitcoinCustomFee(lastFee.toString()); this.composed.custom = tx; return true; @@ -118,9 +118,9 @@ export class TransactionComposer { const tx = this.compose(fee); this.composed.custom = tx; if (tx.type === 'final') { - this.feeLevels.updateCustomFee(tx.feePerByte); + this.feeLevels.updateBitcoinCustomFee(tx.feePerByte); } else { - this.feeLevels.updateCustomFee(fee); + this.feeLevels.updateBitcoinCustomFee(fee); } } diff --git a/packages/connect/src/api/bitcoin/__tests__/Fees.test.ts b/packages/connect/src/api/bitcoin/__tests__/BitcoinFees.test.ts similarity index 91% rename from packages/connect/src/api/bitcoin/__tests__/Fees.test.ts rename to packages/connect/src/api/bitcoin/__tests__/BitcoinFees.test.ts index 50b82e8e39b..22658d51d42 100644 --- a/packages/connect/src/api/bitcoin/__tests__/Fees.test.ts +++ b/packages/connect/src/api/bitcoin/__tests__/BitcoinFees.test.ts @@ -4,7 +4,7 @@ import coinsJSON from '@trezor/connect-common/files/coins.json'; import { initBlockchain } from '../../../backend/BlockchainLink'; import { getBitcoinNetwork, parseCoinsJson } from '../../../data/coinInfo'; -import { FeeLevels } from '../Fees'; +import { BitcoinFeeLevels } from '../BitcoinFees'; describe('api/bitcoin/Fees', () => { // load coin definitions @@ -29,7 +29,7 @@ describe('api/bitcoin/Fees', () => { }); const backend = await initBlockchain(coinInfo, () => {}); - const feeLevels = new FeeLevels(coinInfo); + const feeLevels = new BitcoinFeeLevels(coinInfo); expect(feeLevels.levels.length).toEqual(4); // Bitcoin has 4 defined levels in coins.json expect(feeLevels.levels.map(l => l.feePerUnit)).toEqual( @@ -37,7 +37,7 @@ describe('api/bitcoin/Fees', () => { ); // preloaded values from coins.json const smartFeeLevels = await feeLevels.load(backend); - expect(smartFeeLevels.map(l => l.feePerUnit)).toEqual(['10', '10', '8.86', '4.69']); + expect(smartFeeLevels?.map(l => l.feePerUnit)).toEqual(['10', '10', '8.86', '4.69']); backend.disconnect(); spy.mockClear(); @@ -63,10 +63,10 @@ describe('api/bitcoin/Fees', () => { }); const backend = await initBlockchain(coinInfo, () => {}); - const feeLevels = new FeeLevels(coinInfo); + const feeLevels = new BitcoinFeeLevels(coinInfo); const smartFeeLevels = await feeLevels.load(backend); - expect(smartFeeLevels.map(l => l.feePerUnit)).toEqual(['10', '8.86', '3.18', '1']); + expect(smartFeeLevels?.map(l => l.feePerUnit)).toEqual(['10', '8.86', '3.18', '1']); backend.disconnect(); spy.mockClear(); @@ -92,7 +92,7 @@ describe('api/bitcoin/Fees', () => { }); const backend = await initBlockchain(coinInfo, () => {}); - const feeLevels = new FeeLevels(coinInfo); + const feeLevels = new BitcoinFeeLevels(coinInfo); expect(feeLevels.levels.length).toEqual(1); // Testnet has 1 defined levels in coins.json expect(feeLevels.levels.map(l => l.feePerUnit)).toEqual( @@ -100,7 +100,7 @@ describe('api/bitcoin/Fees', () => { ); // preloaded values from coins.json const smartFeeLevels = await feeLevels.load(backend); - expect(smartFeeLevels.map(l => l.feePerUnit)).toEqual(['9.24']); + expect(smartFeeLevels?.map(l => l.feePerUnit)).toEqual(['9.24']); backend.disconnect(); spy.mockClear(); diff --git a/packages/connect/src/api/bitcoin/index.ts b/packages/connect/src/api/bitcoin/index.ts index 00151b872c4..190995958d9 100644 --- a/packages/connect/src/api/bitcoin/index.ts +++ b/packages/connect/src/api/bitcoin/index.ts @@ -1,6 +1,6 @@ export * from './createPendingTx'; export * from './enhanceSignTx'; -export * from './Fees'; +export * from './BitcoinFees'; export * from './inputs'; export * from './outputs'; export * from './refTx'; diff --git a/packages/connect/src/api/blockchainEstimateFee.ts b/packages/connect/src/api/blockchainEstimateFee.ts index be15734136f..2d3044f3ffe 100644 --- a/packages/connect/src/api/blockchainEstimateFee.ts +++ b/packages/connect/src/api/blockchainEstimateFee.ts @@ -2,11 +2,13 @@ import { ERRORS } from '../constants'; import { AbstractMethod, MethodReturnType, Payload } from '../core/AbstractMethod'; -import { FeeLevels } from './bitcoin/Fees'; import { validateParams } from './common/paramsValidator'; import { initBlockchain, isBackendSupported } from '../backend/BlockchainLink'; import { getCoinInfo } from '../data/coinInfo'; import type { CoinInfo } from '../types'; +import { BitcoinFeeLevels } from './bitcoin/BitcoinFees'; +import { MiscFeeLevels } from './common/MiscFees'; +import { EthereumFeeLevels } from './ethereum/EthereumFees'; type Params = { coinInfo: CoinInfo; @@ -72,8 +74,19 @@ 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 = new FeeLevels(coinInfo); + const fees = getFees(); if (request.feeLevels === 'smart') { const backend = await initBlockchain(coinInfo, this.postMessage, identity); await fees.load(backend); diff --git a/packages/connect/src/api/common/MiscFees.ts b/packages/connect/src/api/common/MiscFees.ts new file mode 100644 index 00000000000..c4e36e8cde4 --- /dev/null +++ b/packages/connect/src/api/common/MiscFees.ts @@ -0,0 +1,68 @@ +// origin: https://github.com/trezor/connect/blob/develop/src/js/core/methods/tx/Fees.js + +import { BigNumber } from '@trezor/utils/src/bigNumber'; + +import { Blockchain } from '../../backend/BlockchainLink'; +import type { CoinInfo, FeeLevel } from '../../types'; + +type Blocks = Array; + +export const findBlocksForFee = (feePerUnit: string, blocks: Blocks) => { + const bn = new BigNumber(feePerUnit); + // find first occurrence of value lower or equal than requested + const lower = blocks.find(b => typeof b === 'string' && bn.gte(b)); + if (!lower) return -1; + + // if not found get latest know value + return blocks.indexOf(lower); +}; + +export class MiscFeeLevels { + coinInfo: CoinInfo; + + levels: FeeLevel[]; + longTermFeeRate?: string; // long term fee rate is used by @trezor/utxo-lib composeTx module + + blocks: Blocks = []; + + constructor(coinInfo: CoinInfo) { + this.coinInfo = coinInfo; + this.levels = coinInfo.defaultFees; + } + + async load(blockchain: Blockchain) { + try { + const [response] = await blockchain.estimateFee({ blocks: [1] }); + + //misc coins should have only one FeeLevel (normal) + this.levels[0] = { + ...this.levels[0], + ...response, + // validate `feePerUnit` from the backend + // should be lower than `coinInfo.maxFee` and higher than `coinInfo.minFee` + // xrp sends values from 1 to very high number occasionally + // see: https://github.com/trezor/trezor-suite/blob/develop/packages/blockchain-link/src/workers/ripple/index.ts#L316 + feePerUnit: Math.min( + this.coinInfo.maxFee, + Math.max(this.coinInfo.minFee, parseInt(response.feePerUnit ?? '0', 10)), + ).toString(), + }; + } catch { + // silent + } + + return this.levels; + } + + updateCustomFee(feePerUnit: 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, + }); + } +} diff --git a/packages/connect/src/api/ethereum/EthereumFees.ts b/packages/connect/src/api/ethereum/EthereumFees.ts new file mode 100644 index 00000000000..9d75e0bbc7c --- /dev/null +++ b/packages/connect/src/api/ethereum/EthereumFees.ts @@ -0,0 +1,105 @@ +import { BigNumber } from '@trezor/utils/src/bigNumber'; + +import { Blockchain } from '../../backend/BlockchainLink'; +import type { EthereumNetworkInfo, FeeLevel } from '../../types'; +import { MiscFeeLevels } from '../common/MiscFees'; + +type Blocks = Array; + +export const findBlocksForFee = (feePerUnit: string, blocks: Blocks) => { + const bn = new BigNumber(feePerUnit); + // find first occurrence of value lower or equal than requested + const lower = blocks.find(b => typeof b === 'string' && bn.gte(b)); + if (!lower) return -1; + + // if not found get latest know value + return blocks.indexOf(lower); +}; + +export class EthereumFeeLevels extends MiscFeeLevels { + coinInfo: EthereumNetworkInfo; + levels: FeeLevel[]; + blocks: Blocks = []; + + constructor(coinInfo: EthereumNetworkInfo) { + super(coinInfo); + this.coinInfo = coinInfo; + this.levels = coinInfo.defaultFees; + } + + async load(blockchain: Blockchain) { + try { + const [response] = await blockchain.estimateFee({ blocks: [1] }); + if (response.eip1559) { + type EipResponse1559Level = 'low' | 'medium' | 'high'; + type Eip1559Level = 'low' | 'normal' | 'high'; + const eip1559ResponseLevelKeys = [ + 'low', + 'medium', + 'high', + ] as EipResponse1559Level[]; + + const { eip1559 } = response; + const eip1559Levels = eip1559ResponseLevelKeys.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 label = + levelKey === 'medium' + ? ('normal' as Eip1559Level) + : (levelKey as Eip1559Level); + + 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', + feeLimit: response.feeLimit, + blocks: -1, + }; + }); + + this.levels = [...eip1559Levels]; + } else { + super.load(blockchain); + } + } catch { + // silent + } + + 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/api/__tests__/blockchain.ts b/packages/connect/src/types/api/__tests__/blockchain.ts index 5bcc0ac0b22..4767f0c2d2c 100644 --- a/packages/connect/src/types/api/__tests__/blockchain.ts +++ b/packages/connect/src/types/api/__tests__/blockchain.ts @@ -17,7 +17,7 @@ export const blockchainEstimateFee = async (api: TrezorConnect) => { // @ts-expect-error blocks not present level.blocks.toFixed(); } - level.feePerUnit.toLowerCase(); + level.feePerUnit?.toLowerCase(); level.feeLimit?.toLocaleLowerCase(); level.feePerTx?.toLocaleLowerCase(); }); diff --git a/packages/connect/src/types/fees.ts b/packages/connect/src/types/fees.ts index 33d0d213845..821d10c2002 100644 --- a/packages/connect/src/types/fees.ts +++ b/packages/connect/src/types/fees.ts @@ -8,6 +8,14 @@ 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([ @@ -21,6 +29,12 @@ export const FeeLevel = Type.Object({ blocks: Type.Number(), feeLimit: Type.Optional(Type.String()), // eth gas limit 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()), + maxPriorityFeePerGas: Type.Optional(Type.String()), + maxWaitTimeEstimate: Type.Optional(Type.Number()), + minWaitTimeEstimate: Type.Optional(Type.Number()), }); export type SelectFeeLevel = Static;