При переходе на отправку транзакций в формате EIP-1559 столкнулись с задачей по оценке комиссии за транзакцию в зависимости от ожидаемой скорости. Работали долгое время с одним известным источником транзакций, пока не начали приходить ошибки на запросы. Поиск альтернатив, которые бы дали возможность оценить стоимость комиссии в зависимости от ожидаемой скорости не нашлось. Было принято решение еще раз погрузиться в процесс изучения возможных подходов к решению. Задача стоит в том, чтобы сделать оценку в виде комиссии для скоростей fastest, fast, average, safeLow. О том, как решали задачу и к чему пришли под катом.

Немного о EIP-1559

Для начала поговорим о самом изменении, почему нам нужен новый механизм расчета стоимости топлива за транзакцию. Данное изменение принесло новый способ расчета и определения комиссии за транзакцию. Данное изменения включено с хардфорка London. В новой системе расчета стоимость газа складывается из base fee - базовая комиссия, которая будет сожжена, и комиссии за включение блока (inclusion fee). Значение для base fee зависит от заполненности предыдущего блока и рассчитывается по понятному алгоритму, при отправке транзакции на это значение не удастся повлиять. При этом baseFee не столь сильно может изменяться в цене по сравнению со старой аукционной моделью. Это делает новых подход более предсказуемым.

Составляющая комиссии представлена двумя значениями:

  • maxFeePerGas - максимальная стоимость, которую вы готовы заплатить за газ, состоит из baseFee + priorityFee

  • maxPriorityFeePerGas - составляющая priorityFee

У ноды появился новый эндпоинт eth_maxPriorityFeePerGas, которые позволяет получить ожидаемое значение для priorityFee для включения в новый блок.

Подход к определению цены на газ для транзакции

Для начала возьмем за основу наивный подход без попыток угадать приоритеты. Примеры будут с кодом на Go. Для получения исторических данных по блокам я использовал API Alchemy.

Для управления скоростью можно использовать различные комбинации параметров, управляющих комиссией для майнеров. Значение для maxPriorityFeePerGas получаем из eth_maxPriorityFeePerGas. Далее рассчитываемmaxFeePerGas = maxPriorityFeePerGas + baseFee * 2. Значение для baseFee берем из последнего блока. Умножение значения на 2 позволяет не учитывать возможные увеличения комиссии в зависимости от заполненности блоков, а сразу заложить максимальное значение. Такой подход позволит включить транзакцию в блок, и вероятность будет зависеть только от попадания maxPriorityFeePerGas в ожидания майнеров. То есть комбинацией maxFeePerGas и maxPriorityFeePerGas можно наложить ограничение на включение в блок в зависимости от роста/падения baseFee.

Рассмотрим описанный пример в коде.

package main
import (
	"context"
	"fmt"
	"github.com/ethereum/go-ethereum/ethclient"
	"os"
)
const EthMainnetEndpoint = ""
func weiToGwe(wei int64) float64 {
	gWei := float64(wei) / 1e9
	return gWei
}
func determineEndpoint() string {
	endpoint, present := os.LookupEnv("ENDPOINT")
	if present == false {
		endpoint = EthMainnetEndpoint
	}
	return endpoint
}
func initiateClient(endpoint string) (*ethclient.Client, error) {
	client, err := ethclient.Dial(endpoint)
	if err != nil {
		return nil, err
	}
	fmt.Println("Successfully made connection with endpoint")
	return client, nil
}
func getBaseFeeValue(client *ethclient.Client) (int64, error) {
	lastBlockRec, err := client.BlockByNumber(context.Background(), nil)
	if err != nil {
		return 0, fmt.Errorf("error getting last block data: %w", err)
	}
	lastBlockBaseFeeWei := lastBlockRec.BaseFee().Int64()
	return lastBlockBaseFeeWei, nil

}
func getSuggestedFee(client *ethclient.Client) (int64, error) {
	suggestedFee, err := client.SuggestGasTipCap(context.Background())
	if err != nil {
		return 0, fmt.Errorf("error getting SuggestGasTipCap: %w", err)
	}
	return suggestedFee.Int64(), nil
}
func calculateBasicFees(client *ethclient.Client) {
	nodeLastBlock, err := client.BlockNumber(context.Background())
	if err != nil {
		fmt.Printf("Error getting BlockNumber: %v", err)
	}
	if err != nil {
		fmt.Printf("error creating client: %v", err)
		return
	}
	baseFeeValue, err := getBaseFeeValue(client)
	if err != nil {
		fmt.Printf("error getting base fee value: %v", err)
	}

	suggestedFeeValue, err := getSuggestedFee(client)
	if err != nil {
		fmt.Printf("error getting suggested fee value: %v", err)
	}

	maxFeePerGas := suggestedFeeValue + baseFeeValue*2

	fmt.Printf("For block %d calculated:\\\\n\\\\tbase fee: %f\\\\n\\\\tmaxFeePerGas: %f\\\\n\\\\tmaxPriorityFeePerGas: %f\\\\n",
		nodeLastBlock, weiToGwe(maxFeePerGas), weiToGwe(maxFeePerGas), weiToGwe(suggestedFeeValue))
	fmt.Println("----")

}

Функция getBaseFeeValue получает значение baseFee из последнего блока. В getSuggestedFee запрашивается значение для maxPriorityFeePerGas. Далее просто рассчитываем maxFeePerGas по формуле. Пример вывода:

For block 14372134 calculated:
base fee: 35.902469
maxFeePerGas: 72.804938
maxPriorityFeePerGas: 1.000000

Данный подход подходит для простого подсчета комиссии. Но не делает отправку транзакции дешевой. Если предыдущие блоки были заполнены, то будет действовать максимальная ставка для baseFee. Скорее всего в этой ситуации и значение maxPriorityFeePerGas будет достаточно высокое. Для экономии комиссии (жертвуя скорость), можно управлять комбинацией maxFeePerGas и maxPriorityFeePerGas. Указав низкое значение для maxPriorityFeePerGas получим ситуацию, когда транзакция становится мало привлекательной. Ограничив maxFeePerGas получаем верхний порог для baseFee = maxFeePerGas - maxPriorityFeePerGas

Расчет стоимости для управления скоростью транзакции

Предыдущая реализация позволит рассчитать комиссию с запасом для практически гарантированного добавления транзакции в следующий блок (особенно есть щедро указать значение для maxPriorityFeePerGas, диапазон для baseFee и так оставили широкий). Но, как правило, необходимо иметь возможность управлять соотношением скорость-цена транзакции, двигая "ползунок" в определенную сторону. В начале статьи указал ожидания по вариантам скорости: fastest, fast, average, safeLow. До введения EIP-1559 достаточно было проанализировать значения для gasPrice транзакций из предыдущего блока для понимания диапазона стоимости газа для транзакции. После изменения мы получили 2 плавающие составляющие: baseFee и priorityFee. Так как повлиять на значение baseFee мы не можем, оно вычисляется протоколом, остается манипулировать значением priorityFee.

Для расчета стоимостей комиссии выполним анализ предыдущих блоков. Необходимо ответить на вопросы:

  • насколько заполнены предыдущие блоки?

  • с какими комиссиями транзакции включены в блок?

В Alchemy есть API, который позволяет получить выгрузку исторических данных по блокам. Если нет желания использовать сторонний API, данный функционал можно реализовать самостоятельно, получая последние блоки и анализируя транзакции из них.

Alchemy предоставляет RPC интерфейс для получения истории блоков, но обработчик не входит в стандартный go-ethereum. Поэтому реализован свой JSON-RPC запрос с использованием библиотеки github.com/ybbus/jsonrpc/v2.

Для вызова метода getHistoryBlocks передаем эндпоинт, количество блоков и процентиль для значений priorityFees из блоков. Преобразуем ответ в более удобный для работы формат, разбив значения для каждого блока. Получаем массив HistoryBlockFees:

  • BlockNumber - номер блока

  • BaseFee - значение baseFee блока

  • PriorityFeesPerGas - значения priorityFees по заданным процентилям

  • UsedRatio - насколько заполнен блок - из этого значения можно предсказать baseFee

Код history_processor.go:

package main
import (
	"fmt"
	"github.com/ybbus/jsonrpc/v2"
	"strconv"
)

type HistoryRewards struct {
	OldestBlock   string     `json:"oldestBlock"`
	Reward        [][]string `json:"reward"`
	BaseFeePerGas []string   `json:"baseFeePerGas"`
	GasUsedRatio  []float64  `json:"gasUsedRatio"`
}

// HistoryBlockFees - history fees from block. all fees are in wei
type HistoryBlockFees struct {
	BlockNumber        int64         `json:"block_number"`
	BaseFee            int64         `json:"base_fee"`
	PriorityFeesPerGas map[int]int64 `json:"priority_fees_per_gas"` // In percentiles
	UsedRatio          float64       `json:"used_ratio"`
}

func hexToInt(hexVal string) (int64, error) {
	hexValStr := hexVal[2:]
	value, err := strconv.ParseInt(hexValStr, 16, 64)
	if err != nil {
		return 0, err
	}
	return value, nil
}
func convertRewardsPercentiles(rewards []string, percentiles []int) (map[int]int64, error) {
	if len(rewards) != len(percentiles) {
		return nil, fmt.Errorf("lengths of rewards and percentiles are different")
	}
	results := make(map[int]int64)
	for i := 0; i < len(percentiles); i++ {
		rewardVal, err := hexToInt(rewards[i])
		if err != nil {
			return nil, fmt.Errorf("error converting reward to int64: %w", err)
		}
		percentile := percentiles[i]
		results[percentile] = rewardVal
	}
	return results, nil
}
func getHistoryBlocks(endpoint string, blocksCount int, expectedRewardsPercentiles []int) ([]HistoryBlockFees, error) {
	callParams := []interface{}{blocksCount, "pending", expectedRewardsPercentiles}
	rewards := &HistoryRewards{}
	rpcClient := jsonrpc.NewClient(endpoint)
	err := rpcClient.CallFor(rewards, "eth_feeHistory", callParams)
	if err != nil {
		return nil, fmt.Errorf("error making call: %w", err)
	}
	blockNumHex := rewards.OldestBlock[2:len(rewards.OldestBlock)]
	blockNum, err := strconv.ParseInt(blockNumHex, 16, 64)
	if err != nil {
		fmt.Printf("error converting block hex number %s to int64\\n", blockNumHex)
	}
	fmt.Printf("Latest block in %d\\n", blockNum)
	baseFees := make([]int64, 0, 4)
	for _, hexVal := range rewards.BaseFeePerGas {
		hexStr := hexVal[2:]
		value, err := strconv.ParseInt(hexStr, 16, 64)
		if err != nil {
			fmt.Printf("error converting %s to int64\\n", hexVal)
		}
		baseFees = append(baseFees, value)
	}
	blocksRewards := make([]HistoryBlockFees, 0, blocksCount)
	var i int64
	for i = 0; i < int64(blocksCount); i++ {
		blockBaseFeeGas := rewards.BaseFeePerGas[i]
		blockBaseFeeGasValue, err := hexToInt(blockBaseFeeGas)
		if err != nil {
			fmt.Printf("error converting %s to int64\\\\n", blockBaseFeeGas)
		}
		priorityFeesPerGas, err := convertRewardsPercentiles(rewards.Reward[i], expectedRewardsPercentiles)
		if err != nil {
			fmt.Printf("error converting priorite fees per gas for block: %e\\\\n", err)
		}
		resultRec := HistoryBlockFees{
			BlockNumber:        blockNum - i,
			BaseFee:            blockBaseFeeGasValue,
			PriorityFeesPerGas: priorityFeesPerGas,
			UsedRatio:          rewards.GasUsedRatio[i],
		}
		blocksRewards = append(blocksRewards, resultRec)
	}
	return blocksRewards, nil

}

Пример выполнения запроса для одного блока:

{
    "block_number": 14372299,
    "base_fee": 33552954122,
    "priority_fees_per_gas":
    {
        "10": 1447045878,
        "20": 1500000000,
        "30": 1500000000,
        "40": 1500000000,
        "50": 1500000000,
        "60": 1500000000,
        "70": 2000000000,
        "80": 2500000000,
        "90": 4336870765
    },
    "used_ratio": 0.9341605645390547
}

Мы видим, что блок почти полностью заполнен. Разброс комиссий в 90% процентиль практически в 2 выше соседней. При этом в середине мы видим одинаковые значения в 1.5 GWei.

Связь между baseFee и usedRatio блока заложена в логику EIP-1559. Если кратко, то логика изменения значения такова: если блок заполнен более чем на половину, значение baseFee возрастет в пределах 12.5%. Если блок заполнен менее чем на половину, комиссия уменьшится в пределах 12.5%.

Первичный вариант реализации - использовать процентиль для расчета примерных комиссий. То есть взять за основу определенные уровни комиссий и посчитать средние по ним за последние блоки. Это и будет значением для priorityFees в зависимости от скорости.

Реализованный с вводом стандарта EIP-1559 метод eth_maxPriorityFeePerGas обещает расчет комиссии на уровне, который позволит включить транзакцию в следующий блок. То есть можно принять результат вызова этого метода за safeLow уровень. Для начала попробуем реализовать аналог данному методу, потом перейдем к реализации различных уровней по скорости.

Код подсчета средних комиссий:

func calculateFeeFromHistory(endpoint string) {
	blocksCount := 10
	expectedRewardsPercentiles := []int{1, 5, 10, 15}
	historyItems, err := getHistoryBlocks(endpoint, blocksCount, expectedRewardsPercentiles)
	if err != nil {
		fmt.Printf("error getting history items: %v", err)
		return
	}
	perPercentilesLevels := make(map[int][]int64)
	for _, percentile := range expectedRewardsPercentiles {
		perPercentilesLevels[percentile] = make([]int64, 0, blocksCount)
	}
	for _, blockRec := range historyItems {
		for percent, feeLevel := range blockRec.PriorityFeesPerGas {
			perPercentilesLevels[percent] = append(perPercentilesLevels[percent], feeLevel)
		}
	}
	feesAverages := make(map[int]int64)
	for percentile, feesValues := range perPercentilesLevels {
		var total int64
		for _, number := range feesValues {
			total = total + number
		}
		average := total / int64(len(feesValues))
		feesAverages[percentile] = average
	}
	for percent, average := range feesAverages {
		fmt.Printf("\\\\tfor %d average value is %f\\\\n", percent, weiToGwe(average))
	}
}

В итоге получаем:

  • для 1% процентиль: 0.793284

  • для 5% процентиль: 0.793284

  • для 10% процентиль: 1.367154

  • для 15% процентиль: 1.417776

При этом получаемое с ноды значение для maxPriorityFeePerGas: 1.034000. То есть где-то между 5% и 10%. Подсчет ближайших значений показал, что лучше всего подходит 5 процентиль. Примем его за safeLow значение.

Далее стоит задача выбрать уровни для остальных требуемых скоростей. Для анализа выполнили выбор уровней с шагом в 5% и проанализировали, в какие моменты есть существенные изменения в ценах. В примере для одного блока видим, что в середине диапазона довольно часть значения комиссий одинаковые. Следующим шагом примем следующие уровни для расчета комиссий для скоростей:

  • fastest - 85%

  • fast - 55%

  • average - 10%

Значения выбраны на основании статистики по изменениям комиссии в зависимости от уровней. Также замерена статистика по сравнению с результатами API BlockNative. Данные значения гибко настраиваются и могут быть в любой момент изменены по результатам исторических замеров реальных транзакций.

Дальнейшие изменения

Чтобы не вызывать API на каждый запрос, нужно кэшировать ответы, и обновлять ожидания скорости для каждого нового блока. Также, для более точного предсказания величины комиссии можно строить модели предсказаний, например, основываясь на заполненности блока и изменении baseFee.

В итоговой реализации взяты уровни для maxPriorityFeePerGas на основании средних за последние 10 блоков. Значение для maxFeePerGas вычисляется c запасом на рост baseFee:

maxFeePerGas = maxPriorityFeePerGas + baseFee * 2

Значение для baseFee берется из последнего блока.

Финальная реализация с HTTP API и кэшированием выложена на github. В данной реализации имеется сервер для ответов на запросы по ценам и воркер, который периодически обращается к ноде и рассчитывает стоимость для нового блока.

Буду рад комментариям, замечаниям, предложениям по улучшению и, конечно же, pull request на гитхабе.

Комментарии (0)