diff --git a/gno.land/pkg/keyscli/root.go b/gno.land/pkg/keyscli/root.go index 5e8bde819bd..a4090bc9a97 100644 --- a/gno.land/pkg/keyscli/root.go +++ b/gno.land/pkg/keyscli/root.go @@ -37,6 +37,10 @@ func NewRootCmd(io commands.IO, base client.BaseOptions) *commands.Command { cfg.OnTxSuccess = func(tx std.Tx, res *ctypes.ResultBroadcastTxCommit) { PrintTxInfo(tx, res, io) } + // OnTxFailure prints metrics (gas, storage, etc.) when a tx fails. + cfg.OnTxFailure = func(tx std.Tx, res *ctypes.ResultBroadcastTxCommit) { + PrintTxMetrics(tx, res, io) + } cmd.AddSubCommands( client.NewAddCmd(cfg, io), client.NewDeleteCmd(cfg, io), @@ -64,6 +68,13 @@ func NewRootCmd(io commands.IO, base client.BaseOptions) *commands.Command { func PrintTxInfo(tx std.Tx, res *ctypes.ResultBroadcastTxCommit, io commands.IO) { io.Println(string(res.DeliverTx.Data)) io.Println("OK!") + PrintTxMetrics(tx, res, io) +} + +// PrintTxMetrics prints common tx metrics (gas, storage, events, info, hash). +// This is used for both success and failure cases so users always see the +// relevant numbers. +func PrintTxMetrics(tx std.Tx, res *ctypes.ResultBroadcastTxCommit, io commands.IO) { io.Println("GAS WANTED:", res.DeliverTx.GasWanted) io.Println("GAS USED: ", res.DeliverTx.GasUsed) io.Println("HEIGHT: ", res.Height) diff --git a/tm2/pkg/crypto/keys/client/common.go b/tm2/pkg/crypto/keys/client/common.go index 901eca1a200..afad0c81728 100644 --- a/tm2/pkg/crypto/keys/client/common.go +++ b/tm2/pkg/crypto/keys/client/common.go @@ -14,6 +14,9 @@ type BaseOptions struct { // OnTxSuccess is called when the transaction tx succeeds. It can, for example, // print info in the result. If OnTxSuccess is nil, print basic info. OnTxSuccess func(tx std.Tx, res *ctypes.ResultBroadcastTxCommit) + // OnTxFailure is called when the transaction tx fails. If nil, failure output + // is minimal. + OnTxFailure func(tx std.Tx, res *ctypes.ResultBroadcastTxCommit) } var DefaultBaseOptions = BaseOptions{ diff --git a/tm2/pkg/crypto/keys/client/maketx.go b/tm2/pkg/crypto/keys/client/maketx.go index 6c11ed7ac6c..d6922e6257c 100644 --- a/tm2/pkg/crypto/keys/client/maketx.go +++ b/tm2/pkg/crypto/keys/client/maketx.go @@ -218,9 +218,7 @@ func ExecSignAndBroadcast( return errors.Wrapf(bres.CheckTx.Error, "check transaction failed: log:%s", bres.CheckTx.Log) } if bres.DeliverTx.IsErr() { - io.Println("TX HASH: ", base64.StdEncoding.EncodeToString(bres.Hash)) - io.Println("INFO: ", bres.DeliverTx.Info) - return errors.Wrapf(bres.DeliverTx.Error, "deliver transaction failed: log:%s", bres.DeliverTx.Log) + return handleDeliverResult(cfg.RootCfg, tx, bres, io) } if cfg.RootCfg.OnTxSuccess != nil { @@ -238,3 +236,16 @@ func ExecSignAndBroadcast( return nil } + +// handleDeliverResult handles a failed DeliverTx by invoking OnTxFailure or printing defaults. +func handleDeliverResult(cfg *BaseCfg, tx std.Tx, bres *types.ResultBroadcastTxCommit, io commands.IO) error { + if cfg.OnTxFailure != nil { + cfg.OnTxFailure(tx, bres) + } else { + io.Println("GAS WANTED:", bres.DeliverTx.GasWanted) + io.Println("GAS USED: ", bres.DeliverTx.GasUsed) + io.Println("EVENTS: ", string(bres.DeliverTx.EncodeEvents())) + io.Println("INFO: ", bres.DeliverTx.Info) + } + return errors.Wrapf(bres.DeliverTx.Error, "deliver transaction failed: log:%s", bres.DeliverTx.Log) +} diff --git a/tm2/pkg/crypto/keys/client/maketx_test.go b/tm2/pkg/crypto/keys/client/maketx_test.go new file mode 100644 index 00000000000..410d313b449 --- /dev/null +++ b/tm2/pkg/crypto/keys/client/maketx_test.go @@ -0,0 +1,61 @@ +package client + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" + ctypes "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/std" +) + +func TestHandleDeliverResultCallsOnFailure(t *testing.T) { + called := false + cfg := &BaseCfg{BaseOptions: BaseOptions{OnTxFailure: func(tx std.Tx, res *ctypes.ResultBroadcastTxCommit) { + called = true + }}} + + tx := std.Tx{} + bres := &ctypes.ResultBroadcastTxCommit{ + DeliverTx: abci.ResponseDeliverTx{ + ResponseBase: abci.ResponseBase{Error: abci.StringError("fail")}, + GasWanted: 10, + GasUsed: 20, + }, + } + + io := commands.NewTestIO() + io.SetOut(commands.WriteNopCloser(&bytes.Buffer{})) + err := handleDeliverResult(cfg, tx, bres, io) + + require.True(t, called, "OnTxFailure should be invoked") + require.Error(t, err) +} + +func TestHandleDeliverResultPrintsDefaultWhenNoCallback(t *testing.T) { + cfg := &BaseCfg{BaseOptions: BaseOptions{}} + tx := std.Tx{} + bres := &ctypes.ResultBroadcastTxCommit{ + DeliverTx: abci.ResponseDeliverTx{ + ResponseBase: abci.ResponseBase{Error: abci.StringError("fail"), Info: "hint"}, + GasWanted: 7, + GasUsed: 9, + }, + } + + buf := &bytes.Buffer{} + io := commands.NewTestIO() + io.SetOut(commands.WriteNopCloser(buf)) + + err := handleDeliverResult(cfg, tx, bres, io) + require.Error(t, err) + + output := buf.String() + require.Contains(t, output, "GAS WANTED: 7") + require.Contains(t, output, "GAS USED: 9") + require.Contains(t, output, "INFO:") + require.Contains(t, output, "hint") +} diff --git a/tm2/pkg/sdk/auth/ante.go b/tm2/pkg/sdk/auth/ante.go index 1e2327f0f53..0634305bdd9 100644 --- a/tm2/pkg/sdk/auth/ante.go +++ b/tm2/pkg/sdk/auth/ante.go @@ -78,7 +78,7 @@ func NewAnteHandler(ak AccountKeeper, bank BankKeeperI, sigGasConsumer Signature switch ex := r.(type) { case store.OutOfGasError: log := fmt.Sprintf( - "out of gas in location: %v; gasWanted: %d, gasUsed: %d", + "out of gas in location: %v; gasWanted: %d, gasUsed: %d (until panic)", ex.Descriptor, tx.Fee.GasWanted, newCtx.GasMeter().GasConsumed(), ) res = abciResult(std.ErrOutOfGas(log)) @@ -408,6 +408,22 @@ func EnsureSufficientMempoolFees(ctx sdk.Context, fee std.Fee) sdk.Result { // SetGasMeter returns a new context with a gas meter set from a given context. func SetGasMeter(ctx sdk.Context, gasLimit int64) sdk.Context { + if ctx.Mode() == sdk.RunTxModeSimulate { + // Cap simulation gas to avoid unbounded consumption; use consensus maxGas + // as a ceiling. Ignore the tx gasLimit here so we can measure full gas usage. + maxGas := int64(-1) + if cp := ctx.ConsensusParams(); cp != nil { + maxGas = cp.Block.MaxGas + } + + if maxGas != int64(-1) { + return ctx.WithGasMeter(store.NewGasMeter(maxGas)) + } + + // If no limit is configured (maxGas == -1), fall back to infinite. + return ctx.WithGasMeter(store.NewInfiniteGasMeter()) + } + // In various cases such as simulation and during the genesis block, we do not // meter any gas utilization. if ctx.BlockHeight() == 0 { diff --git a/tm2/pkg/sdk/auth/ante_test.go b/tm2/pkg/sdk/auth/ante_test.go index ef56885dc3c..e01bfec9718 100644 --- a/tm2/pkg/sdk/auth/ante_test.go +++ b/tm2/pkg/sdk/auth/ante_test.go @@ -948,3 +948,57 @@ func TestInvalidUserFee(t *testing.T) { require.False(t, res2.IsOK()) assert.Contains(t, res2.Log, "Gas price denominations should be equal;") } + +func TestSetGasMeterSimulationCap(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + cp := env.ctx.ConsensusParams() + require.NotNil(t, cp) + require.NotNil(t, cp.Block) + maxGas := cp.Block.MaxGas + require.True(t, maxGas > 0) + + ctxSim := env.ctx.WithMode(sdk.RunTxModeSimulate) + + t.Run("caps to consensus maxGas when higher gas wanted", func(t *testing.T) { + t.Parallel() + ctx := SetGasMeter(ctxSim, maxGas+500) + meter := ctx.GasMeter() + require.Equal(t, maxGas, meter.Limit()) + meter.ConsumeGas(maxGas, "fill to max") + require.Panics(t, func() { + meter.ConsumeGas(1, "over maxGas") + }) + }) + + t.Run("ignores lower gas wanted in simulation and uses consensus cap", func(t *testing.T) { + t.Parallel() + const gasWanted = int64(500) + ctx := SetGasMeter(ctxSim, gasWanted) + meter := ctx.GasMeter() + require.Equal(t, maxGas, meter.Limit()) + meter.ConsumeGas(maxGas, "fill to maxGas cap") + require.Panics(t, func() { + meter.ConsumeGas(1, "over maxGas") + }) + }) +} + +// When no consensus maxGas is configured, simulation should use an infinite meter. +func TestSimulationGasMeterInfiniteWhenNoMaxGas(t *testing.T) { + t.Parallel() + + env := setupTestEnv() + ctx := env.ctx.WithConsensusParams(nil).WithMode(sdk.RunTxModeSimulate) + + ctx = SetGasMeter(ctx, 1) // should be ignored and infinite meter used + meter := ctx.GasMeter() + require.Equal(t, int64(0), meter.Limit()) + + // Consuming a very large amount should not panic. + require.NotPanics(t, func() { + meter.ConsumeGas(1_000_000_000, "huge consume") + }) + require.False(t, meter.IsOutOfGas()) +} diff --git a/tm2/pkg/sdk/baseapp.go b/tm2/pkg/sdk/baseapp.go index c37241fcf60..deddcd39d95 100644 --- a/tm2/pkg/sdk/baseapp.go +++ b/tm2/pkg/sdk/baseapp.go @@ -762,16 +762,26 @@ func (app *BaseApp) runTx(ctx Context, tx Tx) (result Result) { if r := recover(); r != nil { switch ex := r.(type) { case store.OutOfGasError: + gasUsed := ctx.GasMeter().GasConsumed() + maxGas := int64(-1) + if cp := ctx.ConsensusParams(); cp != nil { + maxGas = cp.Block.MaxGas + } + var detail string + if maxGas > 0 && gasUsed >= maxGas { + detail = fmt.Sprintf("(hit consensus maxGas %d)", maxGas) + } log := fmt.Sprintf( - "out of gas, gasWanted: %d, gasUsed: %d location: %v", + "out of gas %s, gasWanted: %d, gasUsed: %d (until panic) location: %v", + detail, gasWanted, - ctx.GasMeter().GasConsumed(), + gasUsed, ex.Descriptor, ) result.Error = ABCIError(std.ErrOutOfGas(log)) result.Log = log result.GasWanted = gasWanted - result.GasUsed = ctx.GasMeter().GasConsumed() + result.GasUsed = gasUsed return default: log := fmt.Sprintf("recovered: %v\nstack:\n%v", r, string(debug.Stack())) @@ -862,6 +872,20 @@ func (app *BaseApp) runTx(ctx Context, tx Tx) (result Result) { result = app.runMsgs(runMsgCtx, msgs, mode) result.GasWanted = gasWanted + // Special case for simulation mode where the gas meter is infinite: + // if we used more gas than was requested, return an out of gas error. + if mode == RunTxModeSimulate && result.Error == nil && + gasWanted > 0 && result.GasUsed > gasWanted { + log := fmt.Sprintf( + "out of gas during simulation; gasWanted: %d, gasUsed: %d", + gasWanted, result.GasUsed, + ) + result.Error = ABCIError(std.ErrOutOfGas(log)) + result.Log = log + + return result + } + // Safety check: don't write the cache state unless we're in DeliverTx. if mode != RunTxModeDeliver { return result diff --git a/tm2/pkg/sdk/baseapp_test.go b/tm2/pkg/sdk/baseapp_test.go index 09bad629d6a..864a165a1f4 100644 --- a/tm2/pkg/sdk/baseapp_test.go +++ b/tm2/pkg/sdk/baseapp_test.go @@ -946,6 +946,148 @@ func TestTxGasLimits(t *testing.T) { } } +func TestConsensusMaxGasMentionedInOutOfGasLog(t *testing.T) { + t.Parallel() + + maxGas := int64(50) + anteOpt := func(bapp *BaseApp) { + bapp.SetAnteHandler(func(ctx Context, tx Tx, simulate bool) (newCtx Context, res Result, abort bool) { + res.GasWanted = 100 + return ctx, res, false + }) + } + routerOpt := func(bapp *BaseApp) { + bapp.Router().AddRoute(routeMsgCounter, newTestHandler(func(ctx Context, msg Msg) Result { + ctx.GasMeter().ConsumeGas(maxGas+10, "burn beyond maxGas") + return Result{} + })) + } + + app := setupBaseApp(t, anteOpt, routerOpt) + app.InitChain(abci.RequestInitChain{ + ChainID: "test-chain", + ConsensusParams: &abci.ConsensusParams{ + Block: &abci.BlockParams{MaxGas: maxGas}, + }, + }) + + header := &bft.Header{ChainID: "test-chain", Height: app.LastBlockHeight() + 1} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + + txBytes, err := amino.Marshal(newTxCounter(0, 1)) + require.NoError(t, err) + + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes}) + + require.True(t, res.IsErr()) + _, ok := res.Error.(std.OutOfGasError) + require.True(t, ok) + assert.Contains(t, res.Log, "hit consensus maxGas") +} + +// Ensure when consensus maxGas is not set (or negative) we still get OOG without mentioning the maxGas hint. +func TestOutOfGasLogWithoutConsensusMaxGasHint(t *testing.T) { + t.Parallel() + + anteOpt := func(bapp *BaseApp) { + bapp.SetAnteHandler(func(ctx Context, tx Tx, simulate bool) (newCtx Context, res Result, abort bool) { + res.GasWanted = 5 + newCtx = ctx.WithGasMeter(store.NewGasMeter(res.GasWanted)) + return newCtx, res, false + }) + } + routerOpt := func(bapp *BaseApp) { + bapp.Router().AddRoute(routeMsgCounter, newTestHandler(func(ctx Context, msg Msg) Result { + ctx.GasMeter().ConsumeGas(10, "burn") + return Result{} + })) + } + + app := setupBaseApp(t, anteOpt, routerOpt) + app.setConsensusParams(&abci.ConsensusParams{ + Block: &abci.BlockParams{MaxGas: -1}, + }) + + header := &bft.Header{ChainID: "test-chain", Height: app.LastBlockHeight() + 1} + app.BeginBlock(abci.RequestBeginBlock{Header: header}) + + txBytes, err := amino.Marshal(newTxCounter(0, 1)) + require.NoError(t, err) + res := app.DeliverTx(abci.RequestDeliverTx{Tx: txBytes}) + + require.True(t, res.IsErr()) + _, ok := res.Error.(std.OutOfGasError) + require.True(t, ok) + assert.NotContains(t, res.Log, "hit consensus maxGas") +} + +func TestSimulationOutOfGasSpecialCase(t *testing.T) { + t.Parallel() + + anteOpt := func(bapp *BaseApp) { + bapp.SetAnteHandler(func(ctx Context, tx Tx, simulate bool) (newCtx Context, res Result, abort bool) { + res.GasWanted = 5 + return ctx, res, false + }) + } + routerOpt := func(bapp *BaseApp) { + bapp.Router().AddRoute(routeMsgCounter, newTestHandler(func(ctx Context, msg Msg) Result { + ctx.GasMeter().ConsumeGas(10, "burn more than wanted") + return Result{} + })) + } + + app := setupBaseApp(t, anteOpt, routerOpt) + app.InitChain(abci.RequestInitChain{ChainID: "test-chain"}) + app.setCheckState(&bft.Header{ChainID: "test-chain", Height: 1}) + + tx := newTxCounter(0, 1) + txBytes := amino.MustMarshal(tx) + res := app.Simulate(txBytes, tx) + + require.True(t, res.IsErr()) + _, ok := res.Error.(std.OutOfGasError) + require.True(t, ok) + assert.Contains(t, res.Log, "out of gas during simulation") +} + +func TestSimulationOutOfGasHitsConsensusMaxGas(t *testing.T) { + t.Parallel() + + maxGas := int64(20) + anteOpt := func(bapp *BaseApp) { + bapp.SetAnteHandler(func(ctx Context, tx Tx, simulate bool) (newCtx Context, res Result, abort bool) { + res.GasWanted = 100 + newCtx = ctx.WithGasMeter(store.NewGasMeter(maxGas)) + return newCtx, res, false + }) + } + routerOpt := func(bapp *BaseApp) { + bapp.Router().AddRoute(routeMsgCounter, newTestHandler(func(ctx Context, msg Msg) Result { + ctx.GasMeter().ConsumeGas(maxGas+5, "burn past consensus cap") + return Result{} + })) + } + + app := setupBaseApp(t, anteOpt, routerOpt) + app.InitChain(abci.RequestInitChain{ + ChainID: "test-chain", + ConsensusParams: &abci.ConsensusParams{ + Block: &abci.BlockParams{MaxGas: maxGas}, + }, + }) + app.setCheckState(&bft.Header{ChainID: "test-chain", Height: 1}) + + tx := newTxCounter(0, 1) + txBytes := amino.MustMarshal(tx) + res := app.Simulate(txBytes, tx) + + require.True(t, res.IsErr()) + _, ok := res.Error.(std.OutOfGasError) + require.True(t, ok) + assert.Contains(t, res.Log, "hit consensus maxGas") +} + // Test that transactions exceeding gas limits fail func TestMaxBlockGasLimits(t *testing.T) { t.Parallel()