Repository: cosmos/cosmos-sdk (Go)

Issue:
[Feature]: Add GasMetering for queries
<!-- < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < < ☺ 
v                            ✰  Thanks for opening an issue! ✰    
v    Before smashing the submit button please review the template.
v    Word of caution: poorly thought-out proposals may be rejected 
v                     without deliberation 
☺ > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > > >  -->

## Summary

Right now queries can DOS nodes. Its currently left up to node operators to just disable certain query routes at higher levels of abstraction, if they want to 1: Enable queries, 2: Don't want their nodes vulnerable.

(e.g. to a TallyVote query)

To get some basic mitigation / resource protection for this by default, we should allow nodes to have a local gas limit for queries. Thus they can configure how complex of a query they are willing to provide.

## Proposal

When a new query is made, that is not in consensus, setup a gas meter. Have app.toml have a new field for `query gas limit`.

As time goes on, we should find a solution to express the difference in cost between historic and live queries, but that can be saved for a second change.

## Prior Art

I am told that query providers in Ethereum do something similar to this. Alchemy has its own "query complexity" scoring rule (distinct from gas), and charges for queries according to this metric. (And also halts processing once a query exceeds the allotted complexity)

Many RPC providers use gas directly as the metric though. 


Discussion:
With historical queries directly going to SS soon, I imagine we wouldn't need to really investigate the cost differences between old and live state. Perhaps queries that go against live state can have a gas meter? Not sure...but we need to think of this in the context of SS and SC separation IMO
Gas metering for queries makes sense regardless of SS and SC though? I don't see how that changes the dynamic of wanting to measure the compute / cost done?

Agreed that measuring the difference between historic and live state isn't as near term important if were about to change that though!
Yes, you're right gas metering should happen on both. It's difficult to have the full mental model of how all this will work. I think queries that route to SS should be significantly cheaper.



Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00f931683b19..37376d22922e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ Ref: https://keepachangelog.com/en/1.0.0/
 ### Features
 
 * (x/bank) [#16795](https://github.com/cosmos/cosmos-sdk/pull/16852) Add `DenomMetadataByQueryString` query in bank module to support metadata query by query string.
+* (baseapp) [#16239](https://github.com/cosmos/cosmos-sdk/pull/16239) Add Gas Limits to allow node operators to resource bound queries.
 
 ### Improvements
 
diff --git a/baseapp/abci.go b/baseapp/abci.go
index 02b2d666c3a2..cc4fa14e4c78 100644
--- a/baseapp/abci.go
+++ b/baseapp/abci.go
@@ -1116,7 +1116,8 @@ func (app *BaseApp) CreateQueryContext(height int64, prove bool) (sdk.Context, e
 	// branch the commit multi-store for safety
 	ctx := sdk.NewContext(cacheMS, app.checkState.ctx.BlockHeader(), true, app.logger).
 		WithMinGasPrices(app.minGasPrices).
-		WithBlockHeight(height)
+		WithBlockHeight(height).
+		WithGasMeter(storetypes.NewGasMeter(app.queryGasLimit))
 
 	if height != lastBlockHeight {
 		rms, ok := app.cms.(*rootmulti.Store)
diff --git a/baseapp/baseapp.go b/baseapp/baseapp.go
index 71a297502b88..959ecf80b38e 100644
--- a/baseapp/baseapp.go
+++ b/baseapp/baseapp.go
@@ -3,6 +3,7 @@ package baseapp
 import (
 	"context"
 	"fmt"
+	"math"
 	"sort"
 	"strconv"
 
@@ -123,6 +124,9 @@ type BaseApp struct {
 	// application parameter store.
 	paramStore ParamStore
 
+	// queryGasLimit defines the maximum gas for queries; unbounded if 0.
+	queryGasLimit uint64
+
 	// The minimum gas prices a validator is willing to accept for processing a
 	// transaction. This is mainly used for DoS and spam prevention.
 	minGasPrices sdk.DecCoins
@@ -192,6 +196,7 @@ func NewBaseApp(
 		msgServiceRouter: NewMsgServiceRouter(),
 		txDecoder:        txDecoder,
 		fauxMerkleMode:   false,
+		queryGasLimit:    math.MaxUint64,
 	}
 
 	for _, option := range options {
diff --git a/baseapp/options.go b/baseapp/options.go
index fbb15a6c5b17..f46bf720883b 100644
--- a/baseapp/options.go
+++ b/baseapp/options.go
@@ -3,6 +3,7 @@ package baseapp
 import (
 	"fmt"
 	"io"
+	"math"
 
 	dbm "github.com/cosmos/cosmos-db"
 
@@ -36,6 +37,15 @@ func SetMinGasPrices(gasPricesStr string) func(*BaseApp) {
 	return func(bapp *BaseApp) { bapp.setMinGasPrices(gasPrices) }
 }
 
+// SetQueryGasLimit returns an option that sets a gas limit for queries.
+func SetQueryGasLimit(queryGasLimit uint64) func(*BaseApp) {
+	if queryGasLimit == 0 {
+		queryGasLimit = math.MaxUint64
+	}
+
+	return func(bapp *BaseApp) { bapp.queryGasLimit = queryGasLimit }
+}
+
 // SetHaltHeight returns a BaseApp option function that sets the halt block height.
 func SetHaltHeight(blockHeight uint64) func(*BaseApp) {
 	return func(bapp *BaseApp) { bapp.setHaltHeight(blockHeight) }
diff --git a/server/config/config.go b/server/config/config.go
index 44b67a9e4cfa..107b3aabf1f1 100644
--- a/server/config/config.go
+++ b/server/config/config.go
@@ -39,6 +39,10 @@ type BaseConfig struct {
 	// specified in this config (e.g. 0.25token1;0.0001token2).
 	MinGasPrices string `mapstructure:"minimum-gas-prices"`
 
+	// The maximum amount of gas a grpc/Rest query may consume.
+	// If set to 0, it is unbounded.
+	QueryGasLimit uint64 `mapstructure:"query-gas-limit"`
+
 	Pruning           string `mapstructure:"pruning"`
 	PruningKeepRecent string `mapstructure:"pruning-keep-recent"`
 	PruningInterval   string `mapstructure:"pruning-interval"`
@@ -225,6 +229,7 @@ func DefaultConfig() *Config {
 	return &Config{
 		BaseConfig: BaseConfig{
 			MinGasPrices:        defaultMinGasPrices,
+			QueryGasLimit:       0,
 			InterBlockCache:     true,
 			Pruning:             pruningtypes.PruningOptionDefault,
 			PruningKeepRecent:   "0",
diff --git a/server/config/toml.go b/server/config/toml.go
index 877913fcf20b..903303073a66 100644
--- a/server/config/toml.go
+++ b/server/config/toml.go
@@ -21,6 +21,10 @@ const DefaultConfigTemplate = `# This is a TOML config file.
 # specified in this config (e.g. 0.25token1;0.0001token2).
 minimum-gas-prices = "{{ .BaseConfig.MinGasPrices }}"
 
+# The maximum gas a query coming over rest/grpc may consume.
+# If this is set to zero, the query can consume an unbounded amount of gas.
+query-gas-limit = "{{ .BaseConfig.QueryGasLimit }}"
+
 # default: the last 362880 states are kept, pruning at 10 block intervals
 # nothing: all historic states will be saved, nothing will be deleted (i.e. archiving node)
 # everything: 2 latest states will be kept; pruning at 10 block intervals.
diff --git a/server/start.go b/server/start.go
index b968ca90fd1d..f6535c4d3474 100644
--- a/server/start.go
+++ b/server/start.go
@@ -49,6 +49,7 @@ const (
 	flagTraceStore         = "trace-store"
 	flagCPUProfile         = "cpu-profile"
 	FlagMinGasPrices       = "minimum-gas-prices"
+	FlagQueryGasLimit      = "query-gas-limit"
 	FlagHaltHeight         = "halt-height"
 	FlagHaltTime           = "halt-time"
 	FlagInterBlockCache    = "inter-block-cache"
@@ -178,6 +179,7 @@ is performed. Note, when enabled, gRPC will also be automatically enabled.
 	cmd.Flags().String(flagTransport, "socket", "Transport protocol: socket, grpc")
 	cmd.Flags().String(flagTraceStore, "", "Enable KVStore tracing to an output file")
 	cmd.Flags().String(FlagMinGasPrices, "", "Minimum gas prices to accept for transactions; Any fee in a tx must meet this minimum (e.g. 0.01photino;0.0001stake)")
+	cmd.Flags().Uint64(FlagQueryGasLimit, 0, "Maximum gas a Rest/Grpc query can consume. Blank and 0 imply unbounded.")
 	cmd.Flags().IntSlice(FlagUnsafeSkipUpgrades, []int{}, "Skip a set of upgrade heights to continue the old binary")
 	cmd.Flags().Uint64(FlagHaltHeight, 0, "Block height at which to gracefully halt the chain and shutdown the node")
 	cmd.Flags().Uint64(FlagHaltTime, 0, "Minimum block time (in Unix seconds) at which to gracefully halt the chain and shutdown the node")
diff --git a/server/util.go b/server/util.go
index fbaf6d0be1d7..4570ecd33136 100644
--- a/server/util.go
+++ b/server/util.go
@@ -516,6 +516,7 @@ func DefaultBaseappOptions(appOpts types.AppOptions) []func(*baseapp.BaseApp) {
 		baseapp.SetIAVLDisableFastNode(cast.ToBool(appOpts.Get(FlagDisableIAVLFastNode))),
 		defaultMempool,
 		baseapp.SetChainID(chainID),
+		baseapp.SetQueryGasLimit(cast.ToUint64(appOpts.Get(FlagQueryGasLimit))),
 	}
 }
 
diff --git a/tools/confix/data/v0.50-app.toml b/tools/confix/data/v0.50-app.toml
index f4596897a805..5e01e120d8b0 100644
--- a/tools/confix/data/v0.50-app.toml
+++ b/tools/confix/data/v0.50-app.toml
@@ -10,6 +10,10 @@
 # specified in this config (e.g. 0.25token1;0.0001token2).
 minimum-gas-prices = "0stake"
 
+# The maximum gas a query coming over rest/grpc may consume.
+# If this is set to zero, the query can consume an unbounded amount of gas.
+query-gas-limit = "0"
+
 # default: the last 362880 states are kept, pruning at 10 block intervals
 # nothing: all historic states will be saved, nothing will be deleted (i.e. archiving node)
 # everything: 2 latest states will be kept; pruning at 10 block intervals.
@@ -226,4 +230,4 @@ max-txs = "5000"
 query_gas_limit = 300000
 # This is the number of wasm vm instances we keep cached in memory for speed-up
 # Warning: this is currently unstable and may lead to crashes, best to keep for 0 unless testing locally
-lru_size = 0
\ No newline at end of file
+lru_size = 0


Test patch:
diff --git a/baseapp/baseapp_test.go b/baseapp/baseapp_test.go
index 7a8c5004692c..0fef6903b776 100644
--- a/baseapp/baseapp_test.go
+++ b/baseapp/baseapp_test.go
@@ -86,6 +86,26 @@ func NewBaseAppSuite(t *testing.T, opts ...func(*baseapp.BaseApp)) *BaseAppSuite
 	}
 }
 
+func getQueryBaseapp(t *testing.T) *baseapp.BaseApp {
+	t.Helper()
+
+	db := dbm.NewMemDB()
+	name := t.Name()
+	app := baseapp.NewBaseApp(name, log.NewTestLogger(t), db, nil)
+
+	_, err := app.FinalizeBlock(&abci.RequestFinalizeBlock{Height: 1})
+	require.NoError(t, err)
+	_, err = app.Commit()
+	require.NoError(t, err)
+
+	_, err = app.FinalizeBlock(&abci.RequestFinalizeBlock{Height: 2})
+	require.NoError(t, err)
+	_, err = app.Commit()
+	require.NoError(t, err)
+
+	return app
+}
+
 func NewBaseAppSuiteWithSnapshots(t *testing.T, cfg SnapshotsConfig, opts ...func(*baseapp.BaseApp)) *BaseAppSuite {
 	t.Helper()
 	snapshotTimeout := 1 * time.Minute
@@ -620,6 +640,60 @@ func TestSetMinGasPrices(t *testing.T) {
 	require.Equal(t, minGasPrices, ctx.MinGasPrices())
 }
 
+type ctxType string
+
+const (
+	QueryCtx   ctxType = "query"
+	CheckTxCtx ctxType = "checkTx"
+)
+
+var ctxTypes = []ctxType{QueryCtx, CheckTxCtx}
+
+func (c ctxType) GetCtx(t *testing.T, bapp *baseapp.BaseApp) sdk.Context {
+	t.Helper()
+	if c == QueryCtx {
+		ctx, err := bapp.CreateQueryContext(1, false)
+		require.NoError(t, err)
+		return ctx
+	} else if c == CheckTxCtx {
+		return getCheckStateCtx(bapp)
+	}
+	// TODO: Not supported yet
+	return getFinalizeBlockStateCtx(bapp)
+}
+
+func TestQueryGasLimit(t *testing.T) {
+	testCases := []struct {
+		queryGasLimit   uint64
+		gasActuallyUsed uint64
+		shouldQueryErr  bool
+	}{
+		{queryGasLimit: 100, gasActuallyUsed: 50, shouldQueryErr: false},  // Valid case
+		{queryGasLimit: 100, gasActuallyUsed: 150, shouldQueryErr: true},  // gasActuallyUsed > queryGasLimit
+		{queryGasLimit: 0, gasActuallyUsed: 50, shouldQueryErr: false},    // fuzzing with queryGasLimit = 0
+		{queryGasLimit: 0, gasActuallyUsed: 0, shouldQueryErr: false},     // both queryGasLimit and gasActuallyUsed are 0
+		{queryGasLimit: 200, gasActuallyUsed: 200, shouldQueryErr: false}, // gasActuallyUsed == queryGasLimit
+		{queryGasLimit: 100, gasActuallyUsed: 1000, shouldQueryErr: true}, // gasActuallyUsed > queryGasLimit
+	}
+
+	for _, tc := range testCases {
+		for _, ctxType := range ctxTypes {
+			t.Run(fmt.Sprintf("%s: %d - %d", ctxType, tc.queryGasLimit, tc.gasActuallyUsed), func(t *testing.T) {
+				app := getQueryBaseapp(t)
+				baseapp.SetQueryGasLimit(tc.queryGasLimit)(app)
+				ctx := ctxType.GetCtx(t, app)
+
+				// query gas limit should have no effect when CtxType != QueryCtx
+				if tc.shouldQueryErr && ctxType == QueryCtx {
+					require.Panics(t, func() { ctx.GasMeter().ConsumeGas(tc.gasActuallyUsed, "test") })
+				} else {
+					require.NotPanics(t, func() { ctx.GasMeter().ConsumeGas(tc.gasActuallyUsed, "test") })
+				}
+			})
+		}
+	}
+}
+
 func TestGetMaximumBlockGas(t *testing.T) {
 	suite := NewBaseAppSuite(t)
 	_, err := suite.baseApp.InitChain(&abci.RequestInitChain{})