diff --git a/CHANGELOG.md b/CHANGELOG.md index 025ebb8dfe3a..77b5c60d83b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * (server) [#24720](https://github.com/cosmos/cosmos-sdk/pull/24720) add `verbose_log_level` flag for configuring the log level when switching to verbose logging mode during sensitive operations (such as chain upgrades). * (crypto) [#24861](https://github.com/cosmos/cosmos-sdk/pull/24861) add `PubKeyFromCometTypeAndBytes` helper function to convert from `comet/v2` PubKeys to the `cryptotypes.Pubkey` interface. * (abci_utils) [#25008](https://github.com/cosmos/cosmos-sdk/pull/25008) add the ability to assign a custom signer extraction adapter in `DefaultProposalHandler`. +* (context) [#25303](https://github.com/cosmos/cosmos-sdk/pull/25303) Add `WithGasRemaining` to execute sub-calls with a specific gas limit. ### Improvements diff --git a/store/CHANGELOG.md b/store/CHANGELOG.md index 0724a1e8ad1c..adfc8462403f 100644 --- a/store/CHANGELOG.md +++ b/store/CHANGELOG.md @@ -29,6 +29,10 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#20425](https://github.com/cosmos/cosmos-sdk/pull/20425) Fix nil pointer panic when querying historical state where a new store does not exist. +### Features + +* [#25303](https://github.com/cosmos/cosmos-sdk/pull/25303) Implement ProxyGasMeter to execute sub-calls with a different gas limit. + ## v1.1.2 (March 31, 2025) ### Bug Fixes diff --git a/store/types/proxygasmeter.go b/store/types/proxygasmeter.go new file mode 100644 index 000000000000..82980eee0769 --- /dev/null +++ b/store/types/proxygasmeter.go @@ -0,0 +1,46 @@ +package types + +import "fmt" + +var _ GasMeter = &ProxyGasMeter{} + +// ProxyGasMeter is like a basicGasMeter, but delegates the gas changes (refund and consume) to the parent GasMeter in +// realtime, ensuring accurate gas accounting even during panics. +type ProxyGasMeter struct { + GasMeter + + parent GasMeter +} + +// NewProxyGasMeter creates a ProxyGasMeter that wraps a parent gas meter, inheriting the minimum of the new limit and the parent's remaining gas. +// It delegates consumption to the parent in real time, ensuring accurate gas accounting even during panics. +// +// Returns the parent directly if the new limit is greater than or equal to its remaining gas. +func NewProxyGasMeter(gasMeter GasMeter, limit Gas) GasMeter { + limit = min(limit, gasMeter.GasRemaining()) + return &ProxyGasMeter{ + GasMeter: NewGasMeter(limit), + parent: gasMeter, + } +} + +// RefundGas will also refund gas to parent gas meter. +func (pgm ProxyGasMeter) RefundGas(amount Gas, descriptor string) { + pgm.parent.RefundGas(amount, descriptor) + pgm.GasMeter.RefundGas(amount, descriptor) +} + +// ConsumeGas will also consume gas from parent gas meter. +// +// it consume sub-gasmeter first, which means if sub-gasmeter runs out of gas, +// the gas is not charged in parent gas meter, the assumption for business logic +// is the gas is always charged before the work is done, so when out-of-gas panic happens, +// the actual work is not done yet, so we don't need to consume the gas in parent gas meter. +func (pgm ProxyGasMeter) ConsumeGas(amount Gas, descriptor string) { + pgm.parent.ConsumeGas(amount, descriptor) + pgm.GasMeter.ConsumeGas(amount, descriptor) +} + +func (pgm ProxyGasMeter) String() string { + return fmt.Sprintf("ProxyGasMeter{consumed: %d, limit: %d}", pgm.GasConsumed(), pgm.Limit()) +} diff --git a/store/types/proxygasmeter_test.go b/store/types/proxygasmeter_test.go new file mode 100644 index 000000000000..c33da6740e7e --- /dev/null +++ b/store/types/proxygasmeter_test.go @@ -0,0 +1,45 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestProxyGasMeter(t *testing.T) { + baseGas := uint64(1000) + limit := uint64(300) + + bgm := NewGasMeter(baseGas) + pgm := NewProxyGasMeter(bgm, limit) + + require.Equal(t, Gas(0), pgm.GasConsumed()) + require.Equal(t, limit, pgm.Limit()) + require.Equal(t, limit, pgm.GasRemaining()) + + pgm.ConsumeGas(100, "test") + require.Equal(t, Gas(100), pgm.GasConsumed()) + require.Equal(t, Gas(100), bgm.GasConsumed()) + require.Equal(t, limit-100, pgm.GasRemaining()) + require.False(t, pgm.IsOutOfGas()) + require.False(t, pgm.IsPastLimit()) + + pgm.ConsumeGas(200, "test") + require.Equal(t, Gas(300), pgm.GasConsumed()) + require.Equal(t, Gas(300), bgm.GasConsumed()) + require.Equal(t, Gas(0), pgm.GasRemaining()) + require.Equal(t, Gas(700), bgm.GasRemaining()) + require.True(t, pgm.IsOutOfGas()) + require.False(t, pgm.IsPastLimit()) + + require.Panics(t, func() { + pgm.ConsumeGas(1, "test") + }) + require.Equal(t, Gas(699), bgm.GasRemaining()) + + pgm.RefundGas(1, "test") + require.Equal(t, Gas(300), pgm.GasConsumed()) + require.Equal(t, Gas(0), pgm.GasRemaining()) + require.True(t, pgm.IsOutOfGas()) + require.False(t, pgm.IsPastLimit()) +} diff --git a/types/context.go b/types/context.go index 4b5cbdeca55b..eea887c06db9 100644 --- a/types/context.go +++ b/types/context.go @@ -224,6 +224,15 @@ func (c Context) WithGasMeter(meter storetypes.GasMeter) Context { return c } +// WithGasLimit replaces the GasMeter with a ProxyGasMeter whose gas limit is set to the minimal of the +// given limit and the remaining gas in the current GasMeter. +// +// ProxyGasMeter will delegate the gas consumption to the parent gas meter in realtime, so there's no need to write +// back the gas consumed to the parent gas meter when the sub-GasMeter is done. +func (c Context) WithGasLimit(limit storetypes.Gas) Context { + return c.WithGasMeter(storetypes.NewProxyGasMeter(c.GasMeter(), limit)) +} + // WithBlockGasMeter returns a Context with an updated block GasMeter func (c Context) WithBlockGasMeter(meter storetypes.GasMeter) Context { c.blockGasMeter = meter diff --git a/types/context_test.go b/types/context_test.go index 975964b39c51..6a86ef9d4ae8 100644 --- a/types/context_test.go +++ b/types/context_test.go @@ -241,3 +241,52 @@ func (s *contextTestSuite) TestUnwrapSDKContext() { sdkCtx2 = types.UnwrapSDKContext(ctx) s.Require().Equal(sdkCtx, sdkCtx2) } + +func (s *contextTestSuite) TestProxyGasMeter() { + ctx := types.NewContext(nil, cmtproto.Header{}, false, nil).WithGasMeter(storetypes.NewGasMeter(20)) + s.Require().EqualValues(20, ctx.GasMeter().Limit()) + + ctx.GasMeter().ConsumeGas(5, "test") + s.Require().EqualValues(5, ctx.GasMeter().GasConsumed()) + s.Require().EqualValues(15, ctx.GasMeter().GasRemaining()) + + { + ctx := ctx.WithGasLimit(10) + s.Require().EqualValues(10, ctx.GasMeter().Limit()) + + ctx.GasMeter().ConsumeGas(5, "test") + s.Require().EqualValues(5, ctx.GasMeter().GasConsumed()) + + s.Require().Panics(func() { + ctx.GasMeter().ConsumeGas(6, "test") + }) + s.Require().EqualValues(11, ctx.GasMeter().GasConsumed()) + } + + s.Require().EqualValues(10, ctx.GasMeter().GasConsumed()) + s.Require().EqualValues(10, ctx.GasMeter().GasRemaining()) + + { + ctx := ctx.WithGasLimit(5) + s.Require().EqualValues(5, ctx.GasMeter().Limit()) + + s.Require().Panics(func() { + ctx.GasMeter().ConsumeGas(6, "test") + }) + s.Require().EqualValues(6, ctx.GasMeter().GasConsumed()) + } + + s.Require().EqualValues(10, ctx.GasMeter().GasConsumed()) + s.Require().EqualValues(10, ctx.GasMeter().GasRemaining()) + + { + ctx := ctx.WithGasLimit(15) + s.Require().EqualValues(10, ctx.GasMeter().Limit()) + + ctx.GasMeter().ConsumeGas(10, "test") + s.Require().EqualValues(10, ctx.GasMeter().GasConsumed()) + } + + s.Require().EqualValues(20, ctx.GasMeter().GasConsumed()) + s.Require().EqualValues(0, ctx.GasMeter().GasRemaining()) +}