From 24167f81f8fdb1df2a90033af8a6512912657b48 Mon Sep 17 00:00:00 2001 From: danvaneijck Date: Wed, 8 Jul 2026 14:22:20 +1200 Subject: [PATCH] feat(evm): add generic read-only CosmWasm query precompile (0x68) Exposes `query(string contractAddress, bytes req) view returns (bytes)` at 0x0000000000000000000000000000000000000068, letting EVM contracts run arbitrary read-only smart-queries against any CosmWasm contract. Query-only by design (no execute/instantiate): no state mutation and no cross-VM reentrancy/atomicity surface. Modeled on the existing oracle precompile. Wasm compute is bounded per call by the wasm module's QueryGasLimit (mirroring wasmd's GrpcQuerier) and out-of-gas is recovered into a clean revert so it can never halt the chain. Co-Authored-By: Claude Opus 4.8 (1M context) --- injective-chain/app/app.go | 9 + .../cosmos/precompile/cosmwasm/ICosmwasm.sol | 19 ++ .../cosmwasm/i_cosmwasm_module.abigen.go | 20 ++ .../evm/precompiles/cosmwasm/cosmwasm.go | 194 ++++++++++++++++++ .../evm/precompiles/cosmwasm/cosmwasm_test.go | 109 ++++++++++ .../modules/evm/precompiles/cosmwasm/doc.go | 20 ++ 6 files changed, 371 insertions(+) create mode 100644 injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/ICosmwasm.sol create mode 100644 injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/i_cosmwasm_module.abigen.go create mode 100644 injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm.go create mode 100644 injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm_test.go create mode 100644 injective-chain/modules/evm/precompiles/cosmwasm/doc.go diff --git a/injective-chain/app/app.go b/injective-chain/app/app.go index fd305bf..72c6627 100644 --- a/injective-chain/app/app.go +++ b/injective-chain/app/app.go @@ -157,6 +157,7 @@ import ( "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm" evmkeeper "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/keeper" bankpc "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/bank" + cosmwasmpc "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/cosmwasm" exchangepc "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/exchange" oraclepc "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/oracle" stakingpc "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/staking" @@ -1191,6 +1192,14 @@ func (app *InjectiveApp) initKeepers(authority string, cfg appconfig.Config, was storetypes.TransientGasConfig(), ) }, + func(_ sdk.Context, _ ethparams.Rules) vm.PrecompiledContract { + // referencing &app.WasmKeeper is safe here: this generator only runs at + // tx time, after WasmKeeper is initialized further below in NewInjectiveApp. + return cosmwasmpc.NewContract( + &app.WasmKeeper, + storetypes.TransientGasConfig(), + ) + }, }, cfg.EVM.EnableGRPCTracing, ) diff --git a/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/ICosmwasm.sol b/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/ICosmwasm.sol new file mode 100644 index 00000000000..34a44b1 --- /dev/null +++ b/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/ICosmwasm.sol @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +address constant COSMWASM_PRECOMPILE_ADDRESS = 0x0000000000000000000000000000000000000068; + +ICosmwasm constant COSMWASM_CONTRACT = ICosmwasm(COSMWASM_PRECOMPILE_ADDRESS); + +/// @title CosmWasm query precompile +/// @notice Read-only access to CosmWasm contracts from the EVM. Query-only: no execute/instantiate. +interface ICosmwasm { + /// @notice Smart-query a CosmWasm contract. + /// @param contractAddress bech32 (inj1...) address of the CosmWasm contract + /// @param req JSON query message, ABI-encoded as bytes + /// @return response JSON response bytes; reverts if the contract does not exist or the query errors + function query(string calldata contractAddress, bytes calldata req) + external + view + returns (bytes memory response); +} diff --git a/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/i_cosmwasm_module.abigen.go b/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/i_cosmwasm_module.abigen.go new file mode 100644 index 00000000000..03843fc --- /dev/null +++ b/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm/i_cosmwasm_module.abigen.go @@ -0,0 +1,20 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. +// +// NOTE: This binding was hand-authored to mirror abigen output because the ABI +// is tiny and stable. Regenerate with scripts/precompiles-bindings.sh once solc/abigen +// are available in the build environment. + +package cosmwasm + +import ( + "github.com/ethereum/go-ethereum/accounts/abi/bind" +) + +// CosmwasmModuleMetaData contains all meta data concerning the CosmwasmModule contract. +var CosmwasmModuleMetaData = &bind.MetaData{ + ABI: "[{\"type\":\"function\",\"name\":\"query\",\"inputs\":[{\"name\":\"contractAddress\",\"type\":\"string\",\"internalType\":\"string\"},{\"name\":\"req\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"outputs\":[{\"name\":\"response\",\"type\":\"bytes\",\"internalType\":\"bytes\"}],\"stateMutability\":\"view\"}]", +} + +// Deprecated: Use CosmwasmModuleMetaData.ABI instead. +var CosmwasmModuleABI = CosmwasmModuleMetaData.ABI diff --git a/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm.go b/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm.go new file mode 100644 index 00000000000..775630b --- /dev/null +++ b/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm.go @@ -0,0 +1,194 @@ +package cosmwasm + +import ( + "context" + "errors" + "math" + + errorsmod "cosmossdk.io/errors" + storetypes "cosmossdk.io/store/types" + "github.com/InjectiveLabs/metrics/v2" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/vm" + + "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles" + bindings "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/bindings/cosmos/precompile/cosmwasm" + precomptypes "github.com/InjectiveLabs/injective-core/injective-chain/modules/evm/precompiles/types" +) + +const ( + // QueryMethodName is the generic read-only smart query: + // query(string contractAddress, bytes req) view returns (bytes response) + QueryMethodName = "query" + + // QueryBaseGas is the flat EVM gas charged per call (plus a per-byte calldata cost). + // + // This is NOT the safety bound: actual wasm compute is hard-capped per call by the wasm module's + // QueryGasLimit (see runQuery) — the same limit wasmd already exposes to the public internet for + // free via gRPC — so any positive fee is already stricter than the status quo and there is no DoS + // to price against. It is therefore a spam-deterrent / consistency knob, set to align with the + // heaviest existing precompile methods (cf. exchange's ~200-250k). Tunable; a candidate for a + // governance param. + QueryBaseGas uint64 = 200_000 +) + +var ( + cosmwasmABI abi.ABI + cosmwasmContractAddress = common.HexToAddress("0x0000000000000000000000000000000000000068") + gasRequiredByMethod = map[[4]byte]uint64{} +) + +var ( + ErrPrecompilePanic = errors.New("cosmwasm precompile panic") + ErrContractNotFound = errors.New("cosmwasm contract not found") + ErrQueryOutOfGas = errors.New("cosmwasm query out of gas") +) + +func init() { + if err := cosmwasmABI.UnmarshalJSON([]byte(bindings.CosmwasmModuleMetaData.ABI)); err != nil { + panic(err) + } + for name, gas := range map[string]uint64{ + QueryMethodName: QueryBaseGas, + } { + method, ok := cosmwasmABI.Methods[name] + if !ok { + panic(name + " method not found in ABI") + } + var id [4]byte + copy(id[:], method.ID[:4]) + gasRequiredByMethod[id] = gas + } +} + +// WasmViewKeeper is the read-only subset of wasmkeeper.Keeper this precompile needs. +// *wasmkeeper.Keeper satisfies it (all methods have value receivers). +type WasmViewKeeper interface { + QuerySmart(ctx context.Context, contractAddr sdk.AccAddress, req []byte) ([]byte, error) + HasContractInfo(ctx context.Context, contractAddr sdk.AccAddress) bool + QueryGasLimit() storetypes.Gas +} + +type Contract struct { + wasmKeeper WasmViewKeeper + kvGasConfig storetypes.GasConfig +} + +func NewContract(wasmKeeper WasmViewKeeper, kvGasConfig storetypes.GasConfig) vm.PrecompiledContract { + return &Contract{ + wasmKeeper: wasmKeeper, + kvGasConfig: kvGasConfig, + } +} + +func (*Contract) ABI() abi.ABI { return cosmwasmABI } +func (*Contract) Address() common.Address { return cosmwasmContractAddress } +func (*Contract) Name() string { return "INJ_COSMWASM" } + +func (c *Contract) RequiredGas(input []byte) uint64 { + if len(input) < 4 { + return 0 + } + // Reject oversized calldata before ABI decoding. + if len(input) > precomptypes.MAX_ABI_ENCODED_CALLDATA_LENGTH { + return math.MaxUint64 + } + + baseCost := uint64(len(input)) * c.kvGasConfig.WriteCostPerByte + var methodID [4]byte + copy(methodID[:], input[:4]) + if methodGas, ok := gasRequiredByMethod[methodID]; ok { + return methodGas + baseCost + } + return baseCost +} + +func (c *Contract) Run(evm *vm.EVM, contract *vm.Contract, readonly bool) ([]byte, error) { + res, err := c.execute(evm, contract, readonly) + if err != nil { + return precomptypes.RevertReasonAndError(err) + } + return res, nil +} + +func (c *Contract) execute(evm *vm.EVM, contract *vm.Contract, _ bool) (output []byte, err error) { + defer func() { + if r := recover(); r != nil { + err = errorsmod.Wrapf(ErrPrecompilePanic, "%v", r) + output = nil + } + }() + + method, err := cosmwasmABI.MethodById(contract.Input[:4]) + if err != nil { + return nil, err + } + + stateDB, ok := evm.StateDB.(precompiles.ExtStateDB) + if !ok { + return nil, errors.New("state DB does not implement ExtStateDB") + } + defer func(origCtx sdk.Context) { *stateDB.ContextPtr() = origCtx }(stateDB.Context()) + defer stateDB.Meter().FuncTiming(stateDB.ContextPtr(), "execute", + metrics.Tag("svc", "cosmwasmpc"), metrics.Tag("method", method.Name))() + + args, err := method.Inputs.Unpack(contract.Input[4:]) + if err != nil { + return nil, errors.New("fail to unpack input arguments") + } + + // Read-only: run against a cache context; nothing is committed back to state. + ctx := stateDB.CacheContext() + switch method.Name { + case QueryMethodName: + return c.query(ctx, method, args) + } + return nil, errors.New("unknown method") +} + +// query(string contractAddress, bytes req) view returns (bytes response) +func (c *Contract) query(ctx sdk.Context, m *abi.Method, args []any) ([]byte, error) { + addrStr, err := precomptypes.CastString(args[0]) + if err != nil { + return nil, err + } + req, ok := args[1].([]byte) + if !ok { + return nil, errors.New("could not cast req to bytes") + } + res, err := c.runQuery(ctx, addrStr, req) + if err != nil { + return nil, err + } + return m.Outputs.Pack(res) +} + +// runQuery bounds and executes a read-only smart query. The EVM executes under an infinite +// Cosmos gas meter, so we install a fresh bounded meter sized to the wasm module's QueryGasLimit +// — exactly what wasmd's own GrpcQuerier does — otherwise the query would be unbounded. A wasm +// out-of-gas surfaces as a Cosmos ErrorOutOfGas panic, which we recover into a clean revert so it +// can never escape and halt the chain. +func (c *Contract) runQuery(ctx sdk.Context, addrStr string, req []byte) (out []byte, err error) { + contractAddr, err := sdk.AccAddressFromBech32(addrStr) + if err != nil { + return nil, err + } + if !c.wasmKeeper.HasContractInfo(ctx, contractAddr) { + return nil, ErrContractNotFound + } + + qCtx := ctx.WithGasMeter(storetypes.NewGasMeter(c.wasmKeeper.QueryGasLimit())) + defer func() { + if r := recover(); r != nil { + if _, ok := r.(storetypes.ErrorOutOfGas); ok { + out, err = nil, ErrQueryOutOfGas + return + } + panic(r) // real panic: re-raise to the outer recover in execute() + } + }() + + return c.wasmKeeper.QuerySmart(qCtx, contractAddr, req) +} diff --git a/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm_test.go b/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm_test.go new file mode 100644 index 00000000000..d58124f --- /dev/null +++ b/injective-chain/modules/evm/precompiles/cosmwasm/cosmwasm_test.go @@ -0,0 +1,109 @@ +package cosmwasm + +import ( + "bytes" + "context" + "math" + "testing" + + storetypes "cosmossdk.io/store/types" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" +) + +// mockWasmKeeper is a stand-in for wasmkeeper.Keeper covering the read-only surface the +// precompile uses. It lets us exercise runQuery without a full app + store. +type mockWasmKeeper struct { + has bool + gasLimit storetypes.Gas + querySmart func(ctx context.Context, addr sdk.AccAddress, req []byte) ([]byte, error) +} + +func (m mockWasmKeeper) HasContractInfo(_ context.Context, _ sdk.AccAddress) bool { return m.has } +func (m mockWasmKeeper) QueryGasLimit() storetypes.Gas { return m.gasLimit } +func (m mockWasmKeeper) QuerySmart(ctx context.Context, addr sdk.AccAddress, req []byte) ([]byte, error) { + return m.querySmart(ctx, addr, req) +} + +func newTestCtx() sdk.Context { + return sdk.Context{}.WithGasMeter(storetypes.NewGasMeter(10_000_000)) +} + +func bech32Of(b byte) string { + return sdk.AccAddress(bytes.Repeat([]byte{b}, 20)).String() +} + +func TestMetadata(t *testing.T) { + c := NewContract(mockWasmKeeper{}, storetypes.TransientGasConfig()) + require.Equal(t, common.HexToAddress("0x0000000000000000000000000000000000000068"), c.Address()) + require.Contains(t, cosmwasmABI.Methods, QueryMethodName) + require.Len(t, cosmwasmABI.Methods, 1, "precompile exposes only the generic query method") +} + +func TestRequiredGas(t *testing.T) { + c := &Contract{wasmKeeper: mockWasmKeeper{}, kvGasConfig: storetypes.TransientGasConfig()} + + require.Equal(t, uint64(0), c.RequiredGas([]byte{0x01}), "sub-selector input is free") + + oversized := make([]byte, 10_001) + require.Equal(t, uint64(math.MaxUint64), c.RequiredGas(oversized), "oversized calldata is rejected") + + sel := cosmwasmABI.Methods[QueryMethodName].ID[:4] + input := append(append([]byte{}, sel...), bytes.Repeat([]byte{0x00}, 64)...) + want := QueryBaseGas + uint64(len(input))*storetypes.TransientGasConfig().WriteCostPerByte + require.Equal(t, want, c.RequiredGas(input)) +} + +func TestRunQuery_HappyPath(t *testing.T) { + expected := []byte(`{"count":42}`) + c := &Contract{ + wasmKeeper: mockWasmKeeper{ + has: true, + gasLimit: 3_000_000, + querySmart: func(_ context.Context, _ sdk.AccAddress, req []byte) ([]byte, error) { + require.Equal(t, `{"count":{}}`, string(req)) + return expected, nil + }, + }, + kvGasConfig: storetypes.TransientGasConfig(), + } + out, err := c.runQuery(newTestCtx(), bech32Of(0x01), []byte(`{"count":{}}`)) + require.NoError(t, err) + require.Equal(t, expected, out) +} + +func TestRunQuery_ContractNotFound(t *testing.T) { + c := &Contract{ + wasmKeeper: mockWasmKeeper{has: false, gasLimit: 3_000_000}, + kvGasConfig: storetypes.TransientGasConfig(), + } + _, err := c.runQuery(newTestCtx(), bech32Of(0x01), []byte(`{}`)) + require.ErrorIs(t, err, ErrContractNotFound) +} + +func TestRunQuery_InvalidAddress(t *testing.T) { + c := &Contract{ + wasmKeeper: mockWasmKeeper{has: true, gasLimit: 3_000_000}, + kvGasConfig: storetypes.TransientGasConfig(), + } + _, err := c.runQuery(newTestCtx(), "not-a-bech32-address", []byte(`{}`)) + require.Error(t, err) +} + +func TestRunQuery_OutOfGasRecovered(t *testing.T) { + c := &Contract{ + wasmKeeper: mockWasmKeeper{ + has: true, + gasLimit: 1_000_000, + querySmart: func(ctx context.Context, _ sdk.AccAddress, _ []byte) ([]byte, error) { + // Simulate an expensive query overrunning the installed gas meter. + sdk.UnwrapSDKContext(ctx).GasMeter().ConsumeGas(2_000_000, "boom") + return []byte("unreached"), nil + }, + }, + kvGasConfig: storetypes.TransientGasConfig(), + } + _, err := c.runQuery(newTestCtx(), bech32Of(0x01), []byte(`{}`)) + require.ErrorIs(t, err, ErrQueryOutOfGas, "wasm OOG must be recovered into a clean error, not a panic") +} diff --git a/injective-chain/modules/evm/precompiles/cosmwasm/doc.go b/injective-chain/modules/evm/precompiles/cosmwasm/doc.go new file mode 100644 index 00000000000..83a7155 --- /dev/null +++ b/injective-chain/modules/evm/precompiles/cosmwasm/doc.go @@ -0,0 +1,20 @@ +// Package cosmwasm provides the CosmWasm query EVM precompile. +// +// The precompile is deployed at address 0x0000000000000000000000000000000000000068 and exposes +// read-only access to CosmWasm contracts from the EVM. It intentionally supports queries ONLY — +// there is no execute/instantiate path — so it cannot mutate Wasm or EVM state and carries no +// cross-VM reentrancy or atomicity surface. +// +// Method: +// +// query(string contractAddress, bytes req) view returns (bytes response) +// Generic smart query. `req` is the JSON query message; `response` is the JSON result bytes. +// +// The precompile is intentionally a generic primitive: it exposes no contract-type-specific helpers. +// Callers decode the JSON response on the Solidity side (e.g. a CW721 owner_of library). On Injective +// an account's inj1 and 0x forms are the same 20 bytes, so an owner returned by a query maps directly +// to an EVM address. +// +// Gas: actual wasm compute is bounded by the wasm module's QueryGasLimit via a freshly installed +// gas meter (mirroring wasmd's GrpcQuerier); an out-of-gas is recovered into a clean revert. +package cosmwasm