From 72deeebe38fa3267a986c418db69dccae43b28fe Mon Sep 17 00:00:00 2001 From: matteyu Date: Wed, 24 Jun 2026 22:02:26 -0700 Subject: [PATCH 1/4] fix: cosmwasm evm query path repeatable undercharged evm exec --- wasmbinding/evm/queries.go | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/wasmbinding/evm/queries.go b/wasmbinding/evm/queries.go index 0ef690e1..c07cd3fc 100644 --- a/wasmbinding/evm/queries.go +++ b/wasmbinding/evm/queries.go @@ -139,7 +139,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the decimals - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "decimals") + callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "decimals") if err != nil { return nil, err } @@ -156,7 +156,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Decimals = decimals // Query the name - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "name") + callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "name") if err != nil { return nil, err } @@ -173,7 +173,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Name = name // Query the symbol - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "symbol") + callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "symbol") if err != nil { return nil, err } @@ -190,7 +190,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Symbol = symbol // Query the total supply - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "totalSupply") + callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "totalSupply") if err != nil { return nil, err } @@ -221,7 +221,7 @@ func (qp *QueryPlugin) HandleERC20Balance(ctx sdk.Context, call *evmbindingtypes stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the balance - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "balanceOf", address) + callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "balanceOf", address) if err != nil { return nil, err } @@ -253,7 +253,7 @@ func (qp *QueryPlugin) HandleERC20Allowance(ctx sdk.Context, call *evmbindingtyp stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the allowance - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, nil, "allowance", owner, spender) + callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "allowance", owner, spender) if err != nil { return nil, err } @@ -303,6 +303,14 @@ func handleRevertError(vmError string, ret []byte) error { return nil } +// queryGasCap returns the gas cap to apply to internal EVM query sub-calls. +// It is bounded by the transaction's remaining SDK gas so that a CosmWasm +// contract cannot force more EVM computation than its transaction pays for, +// and additionally capped by DefaultGasCap to match the eth_call behaviour. +func queryGasCap(ctx sdk.Context) *big.Int { + return new(big.Int).SetUint64(min(ctx.GasMeter().GasRemaining(), emvconfig.DefaultGasCap)) +} + // buildEthCallRequest builds the EVM query call func buildEthCallRequest(ctx sdk.Context, to common.Address, data hexutil.Bytes, chainID *big.Int, proposer []byte) (*evmtypes.EthCallRequest, error) { // Build the arguments From 7f25e714aba5cac5bf4b1902de64e57e4ad08870 Mon Sep 17 00:00:00 2001 From: matteyu Date: Wed, 24 Jun 2026 22:12:15 -0700 Subject: [PATCH 2/4] chore: add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d9c01c..8a94f94c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Indexed admins to reduce query space on tokenfactory denom queries - Fix native token supply inflation from the stateful precompiles by wrapping the account address codec (`evmAddressCodec`) to reject non-20-byte accounts (e.g. a 32-byte bech32 withdraw, module, or CosmWasm contract address) at decode time, preventing such addresses from being truncated and minted a duplicate balance when mirrored into the EVM StateDB - Close governance vote minimum-stake bypass in `GovVoteDecorator` by enforcing the stake check on `MsgVoteWeighted` (`govv1` and `govv1beta1`) and recursing into nested `authz.MsgExec` messages so wrapped votes can no longer skip the requirement +- Bound the CosmWasm→EVM ERC20 query bindings (`wasmbinding/evm/queries.go`) by passing a real `gasCap` derived from the transaction's remaining SDK gas (capped by `DefaultGasCap`) to every internal sub-call (`decimals`, `name`, `symbol`, `totalSupply`, `balanceOf`, `allowance`) instead of `nil`, so a malicious ERC20 can no longer force repeatable, undercharged internal EVM execution against the hardcoded 25M cap ### Removed From 47b3f14382beff2ce541bec4fc5301a390c6b654 Mon Sep 17 00:00:00 2001 From: matteyu Date: Wed, 24 Jun 2026 22:36:41 -0700 Subject: [PATCH 3/4] fix: test wasmbinding --- .github/workflows/test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1f5a14da..7f5c6583 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,6 +47,10 @@ jobs: if: env.GIT_DIFF run: | go test -v -coverprofile=profile.txt -covermode=atomic -coverpkg=./... $(go list ./... | grep -v -e '/tests/e2e' | grep -v -e '/tests/interchain') | grep -v "store.go:" | grep -v "mutable_tree.go:" + - name: wasmbinding coverage (test build tag) + if: env.GIT_DIFF + run: | + go test -tags=test -coverprofile=profile-wasmbinding.txt -covermode=atomic ./wasmbinding/... - uses: actions/upload-artifact@v4 if: env.GIT_DIFF with: @@ -56,7 +60,7 @@ jobs: uses: codecov/codecov-action@v4 if: env.GIT_DIFF with: - files: ./profile.txt + files: ./profile.txt,./profile-wasmbinding.txt token: ${{ secrets.CODECOV_TOKEN }} repo-analysis: From 391c94be70ddfc1f66d0ff7f9dbc6cb319abdbe4 Mon Sep 17 00:00:00 2001 From: matteyu Date: Mon, 29 Jun 2026 22:12:30 -0700 Subject: [PATCH 4/4] fix: charge SDK gas for pre-refund EVM work in wasm ERC20 queries --- CHANGELOG.md | 2 +- wasmbinding/evm/queries.go | 33 +++++++++++++++++++++------------ 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a94f94c..225fd275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,7 +34,7 @@ - Indexed admins to reduce query space on tokenfactory denom queries - Fix native token supply inflation from the stateful precompiles by wrapping the account address codec (`evmAddressCodec`) to reject non-20-byte accounts (e.g. a 32-byte bech32 withdraw, module, or CosmWasm contract address) at decode time, preventing such addresses from being truncated and minted a duplicate balance when mirrored into the EVM StateDB - Close governance vote minimum-stake bypass in `GovVoteDecorator` by enforcing the stake check on `MsgVoteWeighted` (`govv1` and `govv1beta1`) and recursing into nested `authz.MsgExec` messages so wrapped votes can no longer skip the requirement -- Bound the CosmWasm→EVM ERC20 query bindings (`wasmbinding/evm/queries.go`) by passing a real `gasCap` derived from the transaction's remaining SDK gas (capped by `DefaultGasCap`) to every internal sub-call (`decimals`, `name`, `symbol`, `totalSupply`, `balanceOf`, `allowance`) instead of `nil`, so a malicious ERC20 can no longer force repeatable, undercharged internal EVM execution against the hardcoded 25M cap +- Bound the CosmWasm→EVM ERC20 query bindings (`wasmbinding/evm/queries.go`) by routing every internal sub-call (`decimals`, `name`, `symbol`, `totalSupply`, `balanceOf`, `allowance`) through a helper that passes the transaction's remaining SDK gas as the `gasCap` and charges the SDK gas meter for the actual pre-refund EVM work (`MaxUsedGas`), so a malicious ERC20 can no longer force repeatable, undercharged internal EVM execution ### Removed diff --git a/wasmbinding/evm/queries.go b/wasmbinding/evm/queries.go index c07cd3fc..4dab268a 100644 --- a/wasmbinding/evm/queries.go +++ b/wasmbinding/evm/queries.go @@ -8,6 +8,7 @@ import ( "math/big" wasmvmtypes "github.com/CosmWasm/wasmvm/v3/types" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/common/hexutil" "github.com/ethereum/go-ethereum/core/vm" @@ -139,7 +140,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the decimals - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "decimals") + callRes, err := qp.callERC20Query(ctx, stateDB, erc20ABI, to, "decimals") if err != nil { return nil, err } @@ -156,7 +157,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Decimals = decimals // Query the name - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "name") + callRes, err = qp.callERC20Query(ctx, stateDB, erc20ABI, to, "name") if err != nil { return nil, err } @@ -173,7 +174,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Name = name // Query the symbol - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "symbol") + callRes, err = qp.callERC20Query(ctx, stateDB, erc20ABI, to, "symbol") if err != nil { return nil, err } @@ -190,7 +191,7 @@ func (qp *QueryPlugin) HandleERC20Information(ctx sdk.Context, call *evmbindingt res.Symbol = symbol // Query the total supply - callRes, err = qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "totalSupply") + callRes, err = qp.callERC20Query(ctx, stateDB, erc20ABI, to, "totalSupply") if err != nil { return nil, err } @@ -221,7 +222,7 @@ func (qp *QueryPlugin) HandleERC20Balance(ctx sdk.Context, call *evmbindingtypes stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the balance - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "balanceOf", address) + callRes, err := qp.callERC20Query(ctx, stateDB, erc20ABI, to, "balanceOf", address) if err != nil { return nil, err } @@ -253,7 +254,7 @@ func (qp *QueryPlugin) HandleERC20Allowance(ctx sdk.Context, call *evmbindingtyp stateDB := statedb.New(ctx, qp.evmKeeper, statedb.NewEmptyTxConfig()) // Query the allowance - callRes, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, to, false, false, queryGasCap(ctx), "allowance", owner, spender) + callRes, err := qp.callERC20Query(ctx, stateDB, erc20ABI, to, "allowance", owner, spender) if err != nil { return nil, err } @@ -303,12 +304,20 @@ func handleRevertError(vmError string, ret []byte) error { return nil } -// queryGasCap returns the gas cap to apply to internal EVM query sub-calls. -// It is bounded by the transaction's remaining SDK gas so that a CosmWasm -// contract cannot force more EVM computation than its transaction pays for, -// and additionally capped by DefaultGasCap to match the eth_call behaviour. -func queryGasCap(ctx sdk.Context) *big.Int { - return new(big.Int).SetUint64(min(ctx.GasMeter().GasRemaining(), emvconfig.DefaultGasCap)) +// callERC20Query runs an internal ERC20 EVM query and charges the SDK gas meter for the actual pre-refund work +func (qp *QueryPlugin) callERC20Query(ctx sdk.Context, stateDB *statedb.StateDB, erc20ABI abi.ABI, contract common.Address, method string, args ...interface{}) (*evmtypes.MsgEthereumTxResponse, error) { + gasCap := new(big.Int).SetUint64(ctx.GasMeter().GasRemaining()) + + res, err := qp.evmKeeper.CallEVM(ctx, stateDB, erc20ABI, erc20types.ModuleAddress, contract, false, false, gasCap, method, args...) + if err != nil { + return nil, err + } + + if res.MaxUsedGas > res.GasUsed { + ctx.GasMeter().ConsumeGas(res.MaxUsedGas-res.GasUsed, "evm query pre-refund work") + } + + return res, nil } // buildEthCallRequest builds the EVM query call