Пишем gas station для EIP-1559 транзакций

от автора

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


ссылка на оригинал статьи https://habr.com/ru/post/655615/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *