При переходе на отправку транзакций в формате 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 на гитхабе.