Repository: cosmos/relayer (go)

Issue:
RLY should model gas fees for Block SDK enabled chains using the on-chain endpoint
For Osmosis having a hardcoded fee is no longer sufficient since the implementation of erip1559.

Maybe RLY can enable setting the gas fee based on: https://lcd.osmosis.zone/osmosis/txfees/v1beta1/cur_eip_base_fee for enabled chains.

Pull request:
feat: implement support for Osmosis EIP-1559
This PR adds support for querying the dynamic gas price base fee on Osmosis. 

A new config value, `dynamic-gas-price`, is added to the `CosmosProviderConfig`, if this value is true then an ABCI query will be made to fetch the dynamic gas price base fee which is used in place of the configured `gas-prices` value. If the query fails for some reason the relayer will fall back to using the configured `gas-prices` value.

This feature is currently limited to working on Osmosis due to hardcoded values necessary to make it work, but once Skip's Fee Market module is live we can refactor this code to be generic enough to work against any chain for various fee market models.

Closes #1368 

Patch:
diff --git a/relayer/chains/cosmos/cosmos_chain_processor.go b/relayer/chains/cosmos/cosmos_chain_processor.go
index ea405e8..a88cf4f 100644
--- a/relayer/chains/cosmos/cosmos_chain_processor.go
+++ b/relayer/chains/cosmos/cosmos_chain_processor.go
@@ -549,8 +549,15 @@ func (ccp *CosmosChainProcessor) CurrentBlockHeight(ctx context.Context, persist
 
 func (ccp *CosmosChainProcessor) CurrentRelayerBalance(ctx context.Context) {
 	// memoize the current gas prices to only show metrics for "interesting" denoms
+	gasPrice := ccp.chainProvider.PCfg.GasPrices
+
 	if ccp.parsedGasPrices == nil {
-		gp, err := sdk.ParseDecCoins(ccp.chainProvider.PCfg.GasPrices)
+		dynamicFee := ccp.chainProvider.DynamicFee(ctx)
+		if dynamicFee != "" {
+			gasPrice = dynamicFee
+		}
+
+		gp, err := sdk.ParseDecCoins(gasPrice)
 		if err != nil {
 			ccp.log.Error(
 				"Failed to parse gas prices",
@@ -575,11 +582,13 @@ func (ccp *CosmosChainProcessor) CurrentRelayerBalance(ctx context.Context) {
 			zap.Error(err),
 		)
 	}
+
 	// Print the relevant gas prices
 	for _, gasDenom := range *ccp.parsedGasPrices {
 		bal := relayerWalletBalances.AmountOf(gasDenom.Denom)
+
 		// Convert to a big float to get a float64 for metrics
 		f, _ := big.NewFloat(0.0).SetInt(bal.BigInt()).Float64()
-		ccp.metrics.SetWalletBalance(ccp.chainProvider.ChainId(), ccp.chainProvider.PCfg.GasPrices, ccp.chainProvider.Key(), address, gasDenom.Denom, f)
+		ccp.metrics.SetWalletBalance(ccp.chainProvider.ChainId(), gasPrice, ccp.chainProvider.Key(), address, gasDenom.Denom, f)
 	}
 }
diff --git a/relayer/chains/cosmos/fee_market.go b/relayer/chains/cosmos/fee_market.go
new file mode 100644
index 0000000..bc4cb85
--- /dev/null
+++ b/relayer/chains/cosmos/fee_market.go
@@ -0,0 +1,79 @@
+package cosmos
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+	"strings"
+
+	sdkmath "cosmossdk.io/math"
+	"go.uber.org/zap"
+)
+
+const queryPath = "/osmosis.txfees.v1beta1.Query/GetEipBaseFee"
+
+// DynamicFee queries the dynamic gas price base fee and returns a string with the base fee and token denom concatenated.
+// If the chain does not have dynamic fees enabled in the config, nothing happens and an empty string is always returned.
+func (cc *CosmosProvider) DynamicFee(ctx context.Context) string {
+	if !cc.PCfg.DynamicGasPrice {
+		return ""
+	}
+
+	dynamicFee, err := cc.QueryBaseFee(ctx)
+	if err != nil {
+		// If there was an error querying the dynamic base fee, do nothing and fall back to configured gas price.
+		cc.log.Warn("Failed to query the dynamic gas price base fee", zap.Error(err))
+		return ""
+	}
+
+	return dynamicFee
+}
+
+// QueryBaseFee attempts to make an ABCI query to retrieve the base fee on chains using the Osmosis EIP-1559 implementation.
+// This is currently hardcoded to only work on Osmosis.
+func (cc *CosmosProvider) QueryBaseFee(ctx context.Context) (string, error) {
+	resp, err := cc.RPCClient.ABCIQuery(ctx, queryPath, nil)
+	if err != nil || resp.Response.Code != 0 {
+		return "", err
+	}
+
+	// The response value contains the data link escape control character which must be removed before parsing.
+	cleanedString := strings.ReplaceAll(strings.TrimSpace(string(resp.Response.Value)), "\u0010", "")
+
+	decFee, err := sdkmath.LegacyNewDecFromStr(cleanedString)
+	if err != nil {
+		return "", err
+	}
+
+	baseFee, err := decFee.Float64()
+	if err != nil {
+		return "", err
+	}
+
+	// The current EIP-1559 implementation returns an integer and does not return any value that tells us how many
+	// decimal places we need to account for.
+	//
+	// This may be problematic because we are assuming that we always need to move the decimal 18 places.
+	fee := baseFee / 1e18
+
+	denom, err := parseTokenDenom(cc.PCfg.GasPrices)
+	if err != nil {
+		return "", err
+	}
+
+	return fmt.Sprintf("%f%s", fee, denom), nil
+}
+
+// parseTokenDenom takes a string in the format numericGasPrice + tokenDenom (e.g. 0.0025uosmo),
+// and parses the tokenDenom portion (e.g. uosmo) before returning just the token denom.
+func parseTokenDenom(gasPrice string) (string, error) {
+	regex := regexp.MustCompile(`^0\.\d+([a-zA-Z]+)$`)
+
+	matches := regex.FindStringSubmatch(gasPrice)
+
+	if len(matches) != 2 {
+		return "", fmt.Errorf("failed to parse token denom from string %s", gasPrice)
+	}
+
+	return matches[1], nil
+}
diff --git a/relayer/chains/cosmos/grpc_query.go b/relayer/chains/cosmos/grpc_query.go
index cf70a17..1582b96 100644
--- a/relayer/chains/cosmos/grpc_query.go
+++ b/relayer/chains/cosmos/grpc_query.go
@@ -178,7 +178,20 @@ func (cc *CosmosProvider) TxServiceBroadcast(ctx context.Context, req *tx.Broadc
 
 	wg.Add(1)
 
-	if err := cc.broadcastTx(ctx, req.TxBytes, nil, nil, ctx, blockTimeout, []func(*provider.RelayerTxResponse, error){callback}); err != nil {
+	dynamicFee := cc.DynamicFee(ctx)
+
+	err = cc.broadcastTx(
+		ctx,
+		req.TxBytes,
+		nil,
+		nil,
+		ctx,
+		blockTimeout,
+		[]func(*provider.RelayerTxResponse, error){callback},
+		dynamicFee,
+	)
+
+	if err != nil {
 		return nil, err
 	}
 
diff --git a/relayer/chains/cosmos/provider.go b/relayer/chains/cosmos/provider.go
index ab6f635..f34b112 100644
--- a/relayer/chains/cosmos/provider.go
+++ b/relayer/chains/cosmos/provider.go
@@ -32,11 +32,6 @@ var (
 	_ provider.ProviderConfig = &CosmosProviderConfig{}
 )
 
-const (
-	cometEncodingThreshold     = "v0.37.0-alpha"
-	cometBlockResultsThreshold = "v0.38.0-alpha"
-)
-
 type CosmosProviderConfig struct {
 	KeyDirectory     string                     `json:"key-directory" yaml:"key-directory"`
 	Key              string                     `json:"key" yaml:"key"`
@@ -45,6 +40,7 @@ type CosmosProviderConfig struct {
 	RPCAddr          string                     `json:"rpc-addr" yaml:"rpc-addr"`
 	AccountPrefix    string                     `json:"account-prefix" yaml:"account-prefix"`
 	KeyringBackend   string                     `json:"keyring-backend" yaml:"keyring-backend"`
+	DynamicGasPrice  bool                       `json:"dynamic-gas-price" yaml:"dynamic-gas-price"`
 	GasAdjustment    float64                    `json:"gas-adjustment" yaml:"gas-adjustment"`
 	GasPrices        string                     `json:"gas-prices" yaml:"gas-prices"`
 	MinGasAmount     uint64                     `json:"min-gas-amount" yaml:"min-gas-amount"`
diff --git a/relayer/chains/cosmos/tx.go b/relayer/chains/cosmos/tx.go
index 156301b..89d76e0 100644
--- a/relayer/chains/cosmos/tx.go
+++ b/relayer/chains/cosmos/tx.go
@@ -172,7 +172,19 @@ func (cc *CosmosProvider) SendMessagesToMempool(
 	sequenceGuard.Mu.Lock()
 	defer sequenceGuard.Mu.Unlock()
 
-	txBytes, sequence, fees, err := cc.buildMessages(ctx, msgs, memo, 0, txSignerKey, feegranterKeyOrAddr, sequenceGuard)
+	dynamicFee := cc.DynamicFee(ctx)
+
+	txBytes, sequence, fees, err := cc.buildMessages(
+		ctx,
+		msgs,
+		memo,
+		0,
+		txSignerKey,
+		feegranterKeyOrAddr,
+		sequenceGuard,
+		dynamicFee,
+	)
+
 	if err != nil {
 		// Account sequence mismatch errors can happen on the simulated transaction also.
 		if strings.Contains(err.Error(), legacyerrors.ErrWrongSequence.Error()) {
@@ -182,7 +194,18 @@ func (cc *CosmosProvider) SendMessagesToMempool(
 		return err
 	}
 
-	if err := cc.broadcastTx(ctx, txBytes, msgs, fees, asyncCtx, defaultBroadcastWaitTimeout, asyncCallbacks); err != nil {
+	err = cc.broadcastTx(
+		ctx,
+		txBytes,
+		msgs,
+		fees,
+		asyncCtx,
+		defaultBroadcastWaitTimeout,
+		asyncCallbacks,
+		dynamicFee,
+	)
+
+	if err != nil {
 		if strings.Contains(err.Error(), legacyerrors.ErrWrongSequence.Error()) {
 			cc.handleAccountSequenceMismatchError(sequenceGuard, err)
 		}
@@ -253,7 +276,9 @@ func (cc *CosmosProvider) SendMsgsWith(ctx context.Context, msgs []sdk.Msg, memo
 	rand.Seed(time.Now().UnixNano())
 	feegrantKeyAcc, _ := cc.GetKeyAddressForKey(feegranterKey)
 
-	txf, err := cc.PrepareFactory(cc.TxFactory(), signingKey)
+	dynamicFee := cc.DynamicFee(ctx)
+
+	txf, err := cc.PrepareFactory(cc.TxFactory(dynamicFee), signingKey)
 	if err != nil {
 		return nil, err
 	}
@@ -359,6 +384,7 @@ func (cc *CosmosProvider) broadcastTx(
 	asyncCtx context.Context, // context for async wait for block inclusion after successful tx broadcast
 	asyncTimeout time.Duration, // timeout for waiting for block inclusion
 	asyncCallbacks []func(*provider.RelayerTxResponse, error), // callback for success/fail of the wait for block inclusion
+	dynamicFee string,
 ) error {
 	res, err := cc.RPCClient.BroadcastTxSync(ctx, tx)
 	isErr := err != nil
@@ -389,7 +415,7 @@ func (cc *CosmosProvider) broadcastTx(
 		return fmt.Errorf("failed to get relayer bech32 wallet address: %w", err)
 
 	}
-	cc.UpdateFeesSpent(cc.ChainId(), cc.Key(), address, fees)
+	cc.UpdateFeesSpent(cc.ChainId(), cc.Key(), address, fees, dynamicFee)
 
 	// TODO: maybe we need to check if the node has tx indexing enabled?
 	// if not, we need to find a new way to block until inclusion in a block
@@ -596,6 +622,7 @@ func (cc *CosmosProvider) buildMessages(
 	txSignerKey string,
 	feegranterKeyOrAddr string,
 	sequenceGuard *WalletState,
+	dynamicFee string,
 ) (
 	txBytes []byte,
 	sequence uint64,
@@ -607,7 +634,7 @@ func (cc *CosmosProvider) buildMessages(
 
 	cMsgs := CosmosMsgs(msgs...)
 
-	txf, err := cc.PrepareFactory(cc.TxFactory(), txSignerKey)
+	txf, err := cc.PrepareFactory(cc.TxFactory(dynamicFee), txSignerKey)
 	if err != nil {
 		return nil, 0, sdk.Coins{}, err
 	}
@@ -1594,7 +1621,7 @@ func (cc *CosmosProvider) NewClientState(
 	}, nil
 }
 
-func (cc *CosmosProvider) UpdateFeesSpent(chain, key, address string, fees sdk.Coins) {
+func (cc *CosmosProvider) UpdateFeesSpent(chain, key, address string, fees sdk.Coins, dynamicFee string) {
 	// Don't set the metrics in testing
 	if cc.metrics == nil {
 		return
@@ -1604,10 +1631,15 @@ func (cc *CosmosProvider) UpdateFeesSpent(chain, key, address string, fees sdk.C
 	cc.TotalFees = cc.TotalFees.Add(fees...)
 	cc.totalFeesMu.Unlock()
 
+	gasPrice := cc.PCfg.GasPrices
+	if dynamicFee != "" {
+		gasPrice = dynamicFee
+	}
+
 	for _, fee := range cc.TotalFees {
 		// Convert to a big float to get a float64 for metrics
 		f, _ := big.NewFloat(0.0).SetInt(fee.Amount.BigInt()).Float64()
-		cc.metrics.SetFeesSpent(chain, cc.PCfg.GasPrices, key, address, fee.GetDenom(), f)
+		cc.metrics.SetFeesSpent(chain, gasPrice, key, address, fee.GetDenom(), f)
 	}
 }
 
@@ -1780,13 +1812,19 @@ func (cc *CosmosProvider) CalculateGas(ctx context.Context, txf tx.Factory, sign
 }
 
 // TxFactory instantiates a new tx factory with the appropriate configuration settings for this chain.
-func (cc *CosmosProvider) TxFactory() tx.Factory {
+func (cc *CosmosProvider) TxFactory(dynamicFee string) tx.Factory {
+	gasPrice := cc.PCfg.GasPrices
+
+	if dynamicFee != "" {
+		gasPrice = dynamicFee
+	}
+
 	return tx.Factory{}.
 		WithAccountRetriever(cc).
 		WithChainID(cc.PCfg.ChainID).
 		WithTxConfig(cc.Cdc.TxConfig).
 		WithGasAdjustment(cc.PCfg.GasAdjustment).
-		WithGasPrices(cc.PCfg.GasPrices).
+		WithGasPrices(gasPrice).
 		WithKeybase(cc.Keybase).
 		WithSignMode(cc.PCfg.SignMode())
 }


Test patch:
diff --git a/relayer/chains/cosmos/fee_market_test.go b/relayer/chains/cosmos/fee_market_test.go
new file mode 100644
index 0000000..746d31e
--- /dev/null
+++ b/relayer/chains/cosmos/fee_market_test.go
@@ -0,0 +1,61 @@
+package cosmos
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	coinType = 118
+
+	testCfg = CosmosProviderConfig{
+		KeyDirectory:     "",
+		Key:              "default",
+		ChainName:        "osmosis",
+		ChainID:          "osmosis-1",
+		RPCAddr:          "https://rpc.osmosis.strange.love:443",
+		AccountPrefix:    "osmo",
+		KeyringBackend:   "test",
+		DynamicGasPrice:  true,
+		GasAdjustment:    1.2,
+		GasPrices:        "0.0025uosmo",
+		MinGasAmount:     1,
+		MaxGasAmount:     0,
+		Debug:            false,
+		Timeout:          "30s",
+		BlockTimeout:     "30s",
+		OutputFormat:     "json",
+		SignModeStr:      "direct",
+		ExtraCodecs:      nil,
+		Modules:          nil,
+		Slip44:           &coinType,
+		SigningAlgorithm: "",
+		Broadcast:        "batch",
+		MinLoopDuration:  0,
+		ExtensionOptions: nil,
+		FeeGrants:        nil,
+	}
+)
+
+func TestQueryBaseFee(t *testing.T) {
+	p, err := testCfg.NewProvider(nil, t.TempDir(), true, testCfg.ChainName)
+	require.NoError(t, err)
+
+	ctx := context.Background()
+	err = p.Init(ctx)
+	require.NoError(t, err)
+
+	cp := p.(*CosmosProvider)
+
+	baseFee, err := cp.QueryBaseFee(ctx)
+	require.NoError(t, err)
+	require.NotEqual(t, "", baseFee)
+}
+
+func TestParseDenom(t *testing.T) {
+	denom, err := parseTokenDenom(testCfg.GasPrices)
+	require.NoError(t, err)
+	require.Equal(t, "uosmo", denom)
+}