Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement the gas estimation API #4257

Merged
merged 23 commits into from
Jan 23, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ test-race:
# TODO: Remove the -skip flag once the following tests no longer contain data races.
# https://github.com/celestiaorg/celestia-app/issues/1369
@echo "--> Running tests in race mode"
@go test -timeout 15m ./... -v -race -skip "TestPrepareProposalConsistency|TestIntegrationTestSuite|TestBlobstreamRPCQueries|TestSquareSizeIntegrationTest|TestStandardSDKIntegrationTestSuite|TestTxsimCommandFlags|TestTxsimCommandEnvVar|TestMintIntegrationTestSuite|TestBlobstreamCLI|TestUpgrade|TestMaliciousTestNode|TestBigBlobSuite|TestQGBIntegrationSuite|TestSignerTestSuite|TestPriorityTestSuite|TestTimeInPrepareProposalContext|TestBlobstream|TestCLITestSuite|TestLegacyUpgrade|TestSignerTwins|TestConcurrentTxSubmission|TestTxClientTestSuite|Test_testnode|TestEvictions"
@go test -timeout 15m ./... -v -race -skip "TestPrepareProposalConsistency|TestIntegrationTestSuite|TestBlobstreamRPCQueries|TestSquareSizeIntegrationTest|TestStandardSDKIntegrationTestSuite|TestTxsimCommandFlags|TestTxsimCommandEnvVar|TestMintIntegrationTestSuite|TestBlobstreamCLI|TestUpgrade|TestMaliciousTestNode|TestBigBlobSuite|TestQGBIntegrationSuite|TestSignerTestSuite|TestPriorityTestSuite|TestTimeInPrepareProposalContext|TestBlobstream|TestCLITestSuite|TestLegacyUpgrade|TestSignerTwins|TestConcurrentTxSubmission|TestTxClientTestSuite|Test_testnode|TestEvictions|TestEstimateGasUsed|TestEstimateGasPrice"
.PHONY: test-race

## test-bench: Run unit tests in bench mode.
Expand Down
3 changes: 3 additions & 0 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import (
"slices"
"time"

"github.com/celestiaorg/celestia-app/v3/app/grpc/gasestimation"

"github.com/celestiaorg/celestia-app/v3/app/ante"
"github.com/celestiaorg/celestia-app/v3/app/encoding"
celestiatx "github.com/celestiaorg/celestia-app/v3/app/grpc/tx"
Expand Down Expand Up @@ -753,6 +755,7 @@ func (app *App) RegisterAPIRoutes(apiSvr *api.Server, _ config.APIConfig) {
func (app *App) RegisterTxService(clientCtx client.Context) {
authtx.RegisterTxService(app.BaseApp.GRPCQueryRouter(), clientCtx, app.BaseApp.Simulate, app.interfaceRegistry)
celestiatx.RegisterTxService(app.BaseApp.GRPCQueryRouter(), clientCtx, app.interfaceRegistry)
gasestimation.RegisterGasEstimationService(app.BaseApp.GRPCQueryRouter(), clientCtx, app.BaseApp.Simulate)
}

// RegisterTendermintService implements the Application.RegisterTendermintService method.
Expand Down
246 changes: 246 additions & 0 deletions app/grpc/gasestimation/gas_estimator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
package gasestimation

import (
"context"
"fmt"
"math"
"strconv"
"strings"

"github.com/celestiaorg/celestia-app/v3/pkg/appconsts"
"github.com/celestiaorg/celestia-app/v3/x/minfee"
blobtx "github.com/celestiaorg/go-square/v2/tx"
"github.com/cosmos/cosmos-sdk/client"
sdk "github.com/cosmos/cosmos-sdk/types"
gogogrpc "github.com/gogo/protobuf/grpc"
coretypes "github.com/tendermint/tendermint/rpc/core/types"
)

// EstimationZScore is the z-score corresponding to 10% and 90% of the gas prices distribution.
// More information can be found in: https://en.wikipedia.org/wiki/Standard_normal_table#Cumulative_(less_than_Z)
const EstimationZScore = 1.28
Comment on lines +19 to +21
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before we use Z score, IMO we should verify that the gas prices in historical blocks form a normal distribution.

I tried to convey in the ADR that median of top 10% is likely easier and good enough so we don't need to use z scores.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before we use Z score, IMO we should verify that the gas prices in historical blocks form a normal distribution.

Having a normal distribution is optional in our case IMO since we don't need spot on values. There are three cases:

  • a flat distribution: where the values will generally be close the to the mean which is fine
  • a U distribution (I mean just the shape of the distribution forming an U or a W or similar): where we have two cases: either the low prices will be higher than 10%. Or the high prices will be lower than 90%. but I guess that's fine.

I tried to convey in the ADR that median of top 10% is likely easier and good enough so we don't need to use z scores.

got you 👍 I avoided implementing the percentile because it means implementing a bit of complicated math method and maintaining it and I wanted to avoid that. If we can find a library we can use, that has an open source license, I'll definitely change this implementation.


// baseAppSimulateFn is the signature of the Baseapp#Simulate function.
type baseAppSimulateFn func(txBytes []byte) (sdk.GasInfo, *sdk.Result, error)

// RegisterGasEstimationService registers the gas estimation service on the gRPC router.
func RegisterGasEstimationService(qrt gogogrpc.Server, clientCtx client.Context, simulateFn baseAppSimulateFn) {
RegisterGasEstimatorServer(
qrt,
NewGasEstimatorServer(clientCtx, simulateFn),
)
}

var _ GasEstimatorServer = &gasEstimatorServer{}

type gasEstimatorServer struct {
clientCtx client.Context
simulateFn baseAppSimulateFn
}

func NewGasEstimatorServer(clientCtx client.Context, simulateFn baseAppSimulateFn) GasEstimatorServer {
return &gasEstimatorServer{
clientCtx: clientCtx,
simulateFn: simulateFn,
}
}

// lastFiveBlocksTransactionsQuery transaction search query to get all the transactions in the last five blocks.
// the latestHeight param represents the chain's tip height.
func lastFiveBlocksTransactionsQuery(latestHeight int64) string {
startHeight := latestHeight - 5
if startHeight < 0 {
startHeight = 0
}
return fmt.Sprintf("tx.height>%d AND tx.height<=%d", startHeight, latestHeight)
}

// numberOfTransactionsPerPage the number of transactions to return per page in the transaction search
// endpoint.
// Note: the maximum number of transactions per page the endpoint allows is 100.
var numberOfTransactionsPerPage = 100

func (s *gasEstimatorServer) EstimateGasPrice(ctx context.Context, request *EstimateGasPriceRequest) (*EstimateGasPriceResponse, error) {
gasPrice, err := s.estimateGasPrice(ctx, request.TxPriority)
if err != nil {
return nil, err
}
return &EstimateGasPriceResponse{EstimatedGasPrice: gasPrice}, nil
}

// EstimateGasPriceAndUsage takes a transaction priority and a transaction bytes
// and estimates the gas price based on the gas prices of the transactions in the last five blocks.
// If no transaction is found in the last five blocks, return the network
// min gas price.
// It's up to the light client to set the gas price in this case
// to the minimum gas price set by that node.
// The gas used is estimated using the state machine simulation.
func (s *gasEstimatorServer) EstimateGasPriceAndUsage(ctx context.Context, request *EstimateGasPriceAndUsageRequest) (*EstimateGasPriceAndUsageResponse, error) {
// estimate the gas price
gasPrice, err := s.estimateGasPrice(ctx, request.TxPriority)
if err != nil {
return nil, err
}

// estimate the gas used
btx, isBlob, err := blobtx.UnmarshalBlobTx(request.TxBytes)
if isBlob && err != nil {
return nil, err
}

var txBytes []byte
if isBlob {
txBytes = btx.Tx
} else {
txBytes = request.TxBytes
}

gasUsedInfo, _, err := s.simulateFn(txBytes)
if err != nil {
return nil, err
}
return &EstimateGasPriceAndUsageResponse{
EstimatedGasPrice: gasPrice,
EstimatedGasUsed: gasUsedInfo.GasUsed,
}, nil
}

// estimateGasPrice takes a transaction priority and estimates the gas price based
// on the gas prices of the transactions in the last five blocks.
// If no transaction is found in the last five blocks, return the network
// min gas price.
// It's up to the light client to set the gas price in this case
// to the minimum gas price set by that node.
func (s *gasEstimatorServer) estimateGasPrice(ctx context.Context, priority TxPriority) (float64, error) {
status, err := s.clientCtx.Client.Status(ctx)
if err != nil {
return 0, err
}
latestHeight := status.SyncInfo.LatestBlockHeight
page := 1
txSearchResult, err := s.clientCtx.Client.TxSearch(
ctx,
lastFiveBlocksTransactionsQuery(latestHeight),
false,
&page,
&numberOfTransactionsPerPage,
"asc",
)
if err != nil {
return 0, err
}

totalNumberOfTransactions := txSearchResult.TotalCount
if totalNumberOfTransactions == 0 {
// return the min gas price if no transaction found in the last 5 blocks
return minfee.DefaultNetworkMinGasPrice.MustFloat64(), nil
}

gasPrices := make([]float64, 0)
for {
currentPageGasPrices, err := extractGasPriceFromTransactions(txSearchResult.Txs)
if err != nil {
return 0, err
}
gasPrices = append(gasPrices, currentPageGasPrices...)
if len(gasPrices) >= totalNumberOfTransactions {
break
}
page++
txSearchResult, err = s.clientCtx.Client.TxSearch(
ctx,
lastFiveBlocksTransactionsQuery(latestHeight),
false,
&page,
&numberOfTransactionsPerPage,
"asc",
)
if err != nil {
return 0, err
}
}
return estimateGasPriceForTransactions(gasPrices, priority)
}

// estimateGasPriceForTransactions takes a list of transactions and priority
// and returns a gas price estimation.
// The priority sets the estimation as follows:
// - High Priority: The gas price is the price at the start of the top 10% of transactions’ gas prices from the last five blocks.
// - Medium Priority: The gas price is the median of all gas prices from the last five blocks.
// - Low Priority: The gas price is the value at the end of the lowest 10% of gas prices from the last five blocks.
// - Unspecified Priority (default): This is equivalent to the Medium priority, using the median of all gas prices from the last five blocks.
// More information can be found in ADR-023.
func estimateGasPriceForTransactions(gasPrices []float64, priority TxPriority) (float64, error) {
meanGasPrice := Mean(gasPrices)
switch priority {
case TxPriority_TX_PRIORITY_UNSPECIFIED:
return meanGasPrice, nil
case TxPriority_TX_PRIORITY_LOW:
stDev := StandardDeviation(meanGasPrice, gasPrices)
return meanGasPrice - EstimationZScore*stDev, nil
case TxPriority_TX_PRIORITY_MEDIUM:
return meanGasPrice, nil
case TxPriority_TX_PRIORITY_HIGH:
stDev := StandardDeviation(meanGasPrice, gasPrices)
return meanGasPrice + EstimationZScore*stDev, nil
default:
return 0, fmt.Errorf("unknown priority: %d", priority)
}
}

// extractGasPriceFromTransactions takes a list of transaction results
// and returns their corresponding gas prices.
func extractGasPriceFromTransactions(txs []*coretypes.ResultTx) ([]float64, error) {
gasPrices := make([]float64, 0)
for _, tx := range txs {
var feeWithDenom string
for _, event := range tx.TxResult.Events {
// resp.TxResult.Events[4].Attributes[0].Key
if event.GetType() == "tx" {
for _, attr := range event.Attributes {
if string(attr.Key) == "fee" {
feeWithDenom = string(attr.Value)
}
}
}
}
if feeWithDenom == "" {
return nil, fmt.Errorf("couldn't find fee for transaction %s", tx.Hash)
}
feeWithoutDenom, found := strings.CutSuffix(feeWithDenom, appconsts.BondDenom)
if !found {
return nil, fmt.Errorf("couldn't find fee denom for transaction %s: %s", tx.Hash, feeWithDenom)
}
fee, err := strconv.ParseFloat(feeWithoutDenom, 64)
if err != nil {
return nil, fmt.Errorf("couldn't parse fee for transaction %s: %w", tx.Hash, err)
}
gasPrices = append(gasPrices, fee/float64(tx.TxResult.GasWanted))
}
return gasPrices, nil
}

// Mean calculates the mean value of the provided gas prices.
func Mean(gasPrices []float64) float64 {
if len(gasPrices) == 0 {
return 0
}
sum := 0.0
for _, gasPrice := range gasPrices {
sum += gasPrice
}
return sum / float64(len(gasPrices))
}

// StandardDeviation calculates the standard deviation of the provided gas prices.
func StandardDeviation(meanGasPrice float64, gasPrices []float64) float64 {
if len(gasPrices) < 2 {
return 0
}
var variance float64
for _, gasPrice := range gasPrices {
variance += math.Pow(gasPrice-meanGasPrice, 2)
}
variance /= float64(len(gasPrices))
return math.Sqrt(variance)
}
Loading
Loading