Skip to content

feat: post tx hooks #54

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

Merged
merged 10 commits into from
May 6, 2025
Merged
Show file tree
Hide file tree
Changes from all 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 x/vm/keeper/gas.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func (k *Keeper) GetEthIntrinsicGas(ctx sdk.Context, msg core.Message, cfg *para
return core.IntrinsicGas(msg.Data(), msg.AccessList(), isContractCreation, homestead, istanbul)
}

// RefundGas transfers the leftover gas to the sender of the message, caped to half of the total gas
// RefundGas transfers the leftover gas to the sender of the message, capped to half of the total gas
// consumed in the transaction. Additionally, the function sets the total gas consumed to the value
// returned by the EVM execution, thus ignoring the previous intrinsic gas consumed during in the
// AnteHandler.
Expand Down
36 changes: 36 additions & 0 deletions x/vm/keeper/hooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package keeper

import (
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
ethtypes "github.com/ethereum/go-ethereum/core/types"

"github.com/cosmos/evm/x/vm/types"

errorsmod "cosmossdk.io/errors"

sdk "github.com/cosmos/cosmos-sdk/types"
)

// Event Hooks
// These can be utilized to customize evm transaction processing.

var _ types.EvmHooks = MultiEvmHooks{}

// MultiEvmHooks combine multiple evm hooks, all hook functions are run in array sequence
type MultiEvmHooks []types.EvmHooks

// NewMultiEvmHooks combine multiple evm hooks
func NewMultiEvmHooks(hooks ...types.EvmHooks) MultiEvmHooks {
return hooks
}

// PostTxProcessing delegate the call to underlying hooks
func (mh MultiEvmHooks) PostTxProcessing(ctx sdk.Context, sender common.Address, msg core.Message, receipt *ethtypes.Receipt) error {
for i := range mh {
if err := mh[i].PostTxProcessing(ctx, sender, msg, receipt); err != nil {
return errorsmod.Wrapf(err, "EVM hook %T failed", mh[i])
}
}
return nil
}
90 changes: 90 additions & 0 deletions x/vm/keeper/hooks_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package keeper_test

import (
"errors"
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
ethtypes "github.com/ethereum/go-ethereum/core/types"

"github.com/cosmos/evm/x/vm/keeper"
"github.com/cosmos/evm/x/vm/statedb"
"github.com/cosmos/evm/x/vm/types"

sdk "github.com/cosmos/cosmos-sdk/types"
)

// LogRecordHook records all the logs
type LogRecordHook struct {
Logs []*ethtypes.Log
}

func (dh *LogRecordHook) PostTxProcessing(ctx sdk.Context, sender common.Address, msg core.Message, receipt *ethtypes.Receipt) error {
dh.Logs = receipt.Logs
return nil
}

// FailureHook always fail
type FailureHook struct{}

func (dh FailureHook) PostTxProcessing(ctx sdk.Context, sender common.Address, msg core.Message, receipt *ethtypes.Receipt) error {
return errors.New("post tx processing failed")
}

func (suite *KeeperTestSuite) TestEvmHooks() {
testCases := []struct {
msg string
setupHook func() types.EvmHooks
expFunc func(hook types.EvmHooks, result error)
}{
{
"log collect hook",
func() types.EvmHooks {
return &LogRecordHook{}
},
func(hook types.EvmHooks, result error) {
suite.Require().NoError(result)
suite.Require().Equal(1, len((hook.(*LogRecordHook).Logs)))
},
},
{
"always fail hook",
func() types.EvmHooks {
return &FailureHook{}
},
func(hook types.EvmHooks, result error) {
suite.Require().Error(result)
},
},
}

for _, tc := range testCases {
suite.SetupTest()
hook := tc.setupHook()
suite.network.App.EVMKeeper.SetHooks(keeper.NewMultiEvmHooks(hook))

k := suite.network.App.EVMKeeper
ctx := suite.network.GetContext()
txHash := common.BigToHash(big.NewInt(1))
vmdb := statedb.New(ctx, k, statedb.NewTxConfig(
common.BytesToHash(ctx.HeaderHash()),
txHash,
0,
0,
))

vmdb.AddLog(&ethtypes.Log{
Topics: []common.Hash{},
Address: suite.keyring.GetAddr(0),
})
logs := vmdb.Logs()
receipt := &ethtypes.Receipt{
TxHash: txHash,
Logs: logs,
}
result := k.PostTxProcessing(ctx, suite.keyring.GetAddr(0), ethtypes.Message{}, receipt)

tc.expFunc(hook, result)
}
}
27 changes: 27 additions & 0 deletions x/vm/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ type Keeper struct {
// Tracer used to collect execution traces from the EVM transaction execution
tracer string

hooks types.EvmHooks
// EVM Hooks for tx post-processing

// precompiles defines the map of all available precompiled smart contracts.
// Some of these precompiled contracts might not be active depending on the EVM
// parameters.
Expand Down Expand Up @@ -164,6 +167,30 @@ func (k Keeper) GetTxIndexTransient(ctx sdk.Context) uint64 {
return sdk.BigEndianToUint64(store.Get(types.KeyPrefixTransientTxIndex))
}

// ----------------------------------------------------------------------------
// Hooks
// ----------------------------------------------------------------------------

// SetHooks sets the hooks for the EVM module
// Called only once during initialization, panics if called more than once.
func (k *Keeper) SetHooks(eh types.EvmHooks) *Keeper {
if k.hooks != nil {
panic("cannot set evm hooks twice")
}

k.hooks = eh
return k
}

// PostTxProcessing delegates the call to the hooks.
// If no hook has been registered, this function returns with a `nil` error
func (k *Keeper) PostTxProcessing(ctx sdk.Context, sender common.Address, msg core.Message, receipt *ethtypes.Receipt) error {
if k.hooks == nil {
return nil
}
return k.hooks.PostTxProcessing(ctx, sender, msg, receipt)
}

// ----------------------------------------------------------------------------
// Log
// ----------------------------------------------------------------------------
Expand Down
56 changes: 54 additions & 2 deletions x/vm/keeper/state_transition.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
evmcore "github.com/ethereum/go-ethereum/core"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/params"

cmttypes "github.com/cometbft/cometbft/types"
Expand Down Expand Up @@ -144,7 +145,10 @@ func (k Keeper) GetHashFn(ctx sdk.Context) vm.GetHashFunc {
//
// For relevant discussion see: https://github.com/cosmos/cosmos-sdk/discussions/9072
func (k *Keeper) ApplyTransaction(ctx sdk.Context, tx *ethtypes.Transaction) (*types.MsgEthereumTxResponse, error) {
var bloom *big.Int
var (
bloom *big.Int
bloomReceipt ethtypes.Bloom
)

cfg, err := k.EVMConfig(ctx, sdk.ConsAddress(ctx.BlockHeader().ProposerAddress))
if err != nil {
Expand Down Expand Up @@ -179,10 +183,58 @@ func (k *Keeper) ApplyTransaction(ctx sdk.Context, tx *ethtypes.Transaction) (*t
if len(logs) > 0 {
bloom = k.GetBlockBloomTransient(ctx)
bloom.Or(bloom, big.NewInt(0).SetBytes(ethtypes.LogsBloom(logs)))
bloomReceipt = ethtypes.BytesToBloom(bloom.Bytes())
}

if !res.Failed() {
commit()
var contractAddr common.Address
if msg.To() == nil {
contractAddr = crypto.CreateAddress(msg.From(), msg.Nonce())
}

cumulativeGasUsed := res.GasUsed
if ctx.BlockGasMeter() != nil {
limit := ctx.BlockGasMeter().Limit()
cumulativeGasUsed += ctx.BlockGasMeter().GasConsumed()
if cumulativeGasUsed > limit {
cumulativeGasUsed = limit
}
Comment on lines +198 to +201
Copy link
Contributor

Choose a reason for hiding this comment

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

Not too familiar with this code path at this point, but is this the expected behavior? Possibly we should be stopping execution if we've gone over the gas limit and returning a out of gas error?

Copy link
Member Author

@vladjdk vladjdk May 5, 2025

Choose a reason for hiding this comment

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

I viewed some historical Ethermint updates, and it seems like this value from the receipt is only used with external tooling. I still haven't found a concrete example of when something like this could happen, but if you look at

// calculate a minimum amount of gas to be charged to sender if GasLimit
// is considerably higher than GasUsed to stay more aligned with Tendermint gas mechanics
// for more info https://github.com/evmos/ethermint/issues/1085
gasLimit := math.LegacyNewDec(int64(msg.Gas())) //#nosec G115 -- int overflow is not a concern here -- msg gas is not exceeding int64 max value
minGasMultiplier := k.GetMinGasMultiplier(ctx)
minimumGasUsed := gasLimit.Mul(minGasMultiplier)
if !minimumGasUsed.TruncateInt().IsUint64() {
return nil, errorsmod.Wrapf(types.ErrGasOverflow, "minimumGasUsed(%s) is not a uint64", minimumGasUsed.TruncateInt().String())
}
if msg.Gas() < leftoverGas {
return nil, errorsmod.Wrapf(types.ErrGasOverflow, "message gas limit < leftover gas (%d < %d)", msg.Gas(), leftoverGas)
}
gasUsed := math.LegacyMaxDec(minimumGasUsed, math.LegacyNewDec(int64(temporaryGasUsed))).TruncateInt().Uint64() //#nosec G115 -- int overflow is not a concern here
// reset leftoverGas, to be used by the tracer
leftoverGas = msg.Gas() - gasUsed
return &types.MsgEthereumTxResponse{
GasUsed: gasUsed,
VmError: vmError,
, the minimumGasUsed could be higher than the actual gas used, which may cause the cumulativeGasUsed + BlockGasMeter to exceed the limit.

Copy link
Member Author

Choose a reason for hiding this comment

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

I added a test where this case would be hit. This could probably happen in the real world where someone's transaction is at the end of the FIFO list and the gas limit exceeds the rest of the Cosmos block gas limit, but the consumed gas still remains under it. In this case, the EVM would refund the gas.

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe a separate question, but since we're refunding any gas not consumed via res.GasUsed, does that mean the post tx hooks are essentially free to execute?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yeah, added a comment above them to clarify this

}

receipt := &ethtypes.Receipt{
Type: tx.Type(),
PostState: nil,
CumulativeGasUsed: cumulativeGasUsed,
Bloom: bloomReceipt,
Logs: logs,
TxHash: txConfig.TxHash,
ContractAddress: contractAddr,
GasUsed: res.GasUsed,
BlockHash: txConfig.BlockHash,
BlockNumber: big.NewInt(ctx.BlockHeight()),
TransactionIndex: txConfig.TxIndex,
}

signerAddr, err := signer.Sender(tx)
if err != nil {
return nil, errorsmod.Wrap(err, "failed to extract sender address from ethereum transaction")
}

// Note: PostTxProcessing hooks currently do not charge for gas
// and function similar to EndBlockers in abci, but for EVM transactions
if err = k.PostTxProcessing(tmpCtx, signerAddr, msg, receipt); err != nil {
// If hooks returns an error, revert the whole tx.
res.VmError = errorsmod.Wrap(err, "failed to execute post transaction processing").Error()
k.Logger(ctx).Error("tx post processing failed", "error", err)
// If the tx failed in post processing hooks, we should clear the logs
res.Logs = nil
} else if commit != nil {
commit()

// Since the post-processing can alter the log, we need to update the result
res.Logs = types.NewLogsFromEth(receipt.Logs)
ctx.EventManager().EmitEvents(tmpCtx.EventManager().Events())
}
}

evmDenom := types.GetEVMCoinDenom()
Expand Down
50 changes: 50 additions & 0 deletions x/vm/keeper/state_transition_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,56 @@ func (suite *KeeperTestSuite) TestEVMConfig() {
suite.Require().Equal(proposerHextAddress, cfg.CoinBase)
}

func (suite *KeeperTestSuite) TestApplyTransaction() {
suite.enableFeemarket = true
defer func() { suite.enableFeemarket = false }()
// FeeCollector account is pre-funded with enough tokens
// for refund to work
// NOTE: everything should happen within the same block for
// feecollector account to remain funded
suite.SetupTest()
// set bounded cosmos block gas limit
ctx := suite.network.GetContext().WithBlockGasMeter(storetypes.NewGasMeter(1e6))
err := suite.network.App.BankKeeper.MintCoins(ctx, "mint", sdk.NewCoins(sdk.NewCoin("aatom", sdkmath.NewInt(3e18))))
suite.Require().NoError(err)
err = suite.network.App.BankKeeper.SendCoinsFromModuleToModule(ctx, "mint", "fee_collector", sdk.NewCoins(sdk.NewCoin("aatom", sdkmath.NewInt(3e18))))
suite.Require().NoError(err)
testCases := []struct {
name string
gasLimit uint64
requireErr bool
errorMsg string
}{
{
"pass - set evm limit above cosmos block gas limit and refund",
6e6,
false,
"",
},
}

for _, tc := range testCases {
suite.Run(fmt.Sprintf("Case %s", tc.name), func() {
tx, err := suite.factory.GenerateSignedEthTx(suite.keyring.GetPrivKey(0), types.EvmTxArgs{
GasLimit: tc.gasLimit,
})
suite.Require().NoError(err)
initialBalance := suite.network.App.BankKeeper.GetBalance(ctx, suite.keyring.GetAccAddr(0), "aatom")

ethTx := tx.GetMsgs()[0].(*types.MsgEthereumTx).AsTransaction()
res, err := suite.network.App.EVMKeeper.ApplyTransaction(ctx, ethTx)
suite.Require().NoError(err)
suite.Require().Equal(res.GasUsed, uint64(3e6))
// Half of the gas should be refunded based on the protocol refund cap.
// Note that the balance should only increment by the refunded amount
// because ApplyTransaction does not consume and take the gas from the user.
balanceAfterRefund := suite.network.App.BankKeeper.GetBalance(ctx, suite.keyring.GetAccAddr(0), "aatom")
expectedRefund := new(big.Int).Mul(new(big.Int).SetUint64(6e6/2), suite.network.App.EVMKeeper.GetBaseFee(ctx))
suite.Require().Equal(balanceAfterRefund.Sub(initialBalance).Amount, sdkmath.NewIntFromBigInt(expectedRefund))
})
}
}

func (suite *KeeperTestSuite) TestApplyMessage() {
suite.enableFeemarket = true
defer func() { suite.enableFeemarket = false }()
Expand Down
8 changes: 8 additions & 0 deletions x/vm/types/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"math/big"

"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core"
ethtypes "github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/core/vm"

feemarkettypes "github.com/cosmos/evm/x/feemarket/types"
Expand Down Expand Up @@ -57,6 +59,12 @@ type Erc20Keeper interface {
GetERC20PrecompileInstance(ctx sdk.Context, address common.Address) (contract vm.PrecompiledContract, found bool, err error)
}

// EvmHooks event hooks for evm tx processing
type EvmHooks interface {
// Must be called after tx is processed successfully, if return an error, the whole transaction is reverted.
PostTxProcessing(ctx sdk.Context, sender common.Address, msg core.Message, receipt *ethtypes.Receipt) error
}

// BankWrapper defines the methods required by the wrapper around
// the Cosmos SDK x/bank keeper that is used to manage an EVM coin
// with a configurable value for decimals.
Expand Down
Loading