From 993cc964d46e5b70d7b1739d438abace695e6bbb Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Thu, 2 Jul 2026 18:23:37 +0700 Subject: [PATCH 1/4] fix(vm,erc20): decode pre-migration EVM state for historical queries Historical eth_call (and estimateGas/traces) below the store-migration height failed or returned wrong results, because pre-migration state is decoded with the current, incompatible layout: - x/vm Params: proto field numbers shifted (old field 10 active_static_precompiles vs new history_serve_window), so decoding panics with "wrong wireType = 2 for field HistoryServeWindow". - x/erc20 precompiles: the native/dynamic lists moved from a single concatenated-blob key to per-address keys, so precompiles appear unregistered at historical heights and calls execute the decoy ERC20 bytecode (returning 0 or reverting) instead of the precompile. Add a lazy, height-gated shim on the read path: - utils.LazyUpgradeHeight resolves and caches the migration height from a caller-supplied resolver (nil = disabled, keeping the modules generic). - x/vm GetParams decodes the legacy Params layout below that height via types.DecodeLegacyParams. - x/erc20 IsNative/IsDynamicPrecompileAvailable fall back to the legacy concatenated-blob keys below that height. Query-path only: on current heights the code path is byte-for-byte unchanged, so this is not consensus-affecting and ships as a plain binary update. The resolver is wired from the applied x/upgrade done-height on the app side. --- utils/lazy_upgrade_height.go | 63 +++++++++++++++ utils/lazy_upgrade_height_test.go | 87 ++++++++++++++++++++ x/erc20/keeper/keeper.go | 18 +++++ x/erc20/keeper/legacy_precompiles_test.go | 58 +++++++++++++ x/erc20/keeper/precompiles.go | 43 ++++++++++ x/vm/keeper/keeper.go | 14 ++++ x/vm/keeper/params.go | 13 +++ x/vm/types/legacy_params.go | 64 +++++++++++++++ x/vm/types/legacy_params_test.go | 99 +++++++++++++++++++++++ 9 files changed, 459 insertions(+) create mode 100644 utils/lazy_upgrade_height.go create mode 100644 utils/lazy_upgrade_height_test.go create mode 100644 x/erc20/keeper/legacy_precompiles_test.go create mode 100644 x/vm/types/legacy_params.go create mode 100644 x/vm/types/legacy_params_test.go diff --git a/utils/lazy_upgrade_height.go b/utils/lazy_upgrade_height.go new file mode 100644 index 0000000000..f6625fde11 --- /dev/null +++ b/utils/lazy_upgrade_height.go @@ -0,0 +1,63 @@ +package utils + +import ( + "sync/atomic" + + sdk "github.com/cosmos/cosmos-sdk/types" +) + +// LazyUpgradeHeight lazily resolves and caches the block height at which a +// one-off store migration was applied on this chain. It is used on read paths +// that must decode pre-migration state differently below that height (e.g. +// historical eth_call against state written before an upgrade re-encoded it). +// +// resolve is expected to read the applied x/upgrade done-height, so it only +// returns a positive value once the upgrade has executed; before then it stays 0 +// and is re-read cheaply. The height is monotonic (0 until applied, then a fixed +// positive value), so the first non-zero result is memoized and hot paths pay +// only an atomic load afterwards. +type LazyUpgradeHeight struct { + resolve func(ctx sdk.Context) int64 + cached atomic.Int64 +} + +// NewLazyUpgradeHeight returns a resolver gate, or nil if resolve is nil (which +// callers treat as "feature disabled", preserving pre-migration-agnostic +// behavior for chains that never went through the migration). +func NewLazyUpgradeHeight(resolve func(ctx sdk.Context) int64) *LazyUpgradeHeight { + if resolve == nil { + return nil + } + return &LazyUpgradeHeight{resolve: resolve} +} + +// Height returns the resolved migration height, or 0 when disabled or not yet +// applied. Safe to call on a nil receiver. +func (l *LazyUpgradeHeight) Height(ctx sdk.Context) int64 { + if l == nil { + return 0 + } + if h := l.cached.Load(); h != 0 { + return h + } + h := l.resolve(ctx) + if h > 0 { + l.cached.Store(h) + } + return h +} + +// Below reports whether the context is at a positive height strictly below the +// resolved migration height, i.e. reading pre-migration state. Safe to call on a +// nil receiver (returns false). +func (l *LazyUpgradeHeight) Below(ctx sdk.Context) bool { + if l == nil { + return false + } + height := ctx.BlockHeight() + if height <= 0 { + return false + } + migrationHeight := l.Height(ctx) + return migrationHeight > 0 && height < migrationHeight +} diff --git a/utils/lazy_upgrade_height_test.go b/utils/lazy_upgrade_height_test.go new file mode 100644 index 0000000000..571dde4e57 --- /dev/null +++ b/utils/lazy_upgrade_height_test.go @@ -0,0 +1,87 @@ +package utils_test + +import ( + "testing" + + sdk "github.com/cosmos/cosmos-sdk/types" + + "github.com/cosmos/evm/utils" +) + +func TestLazyUpgradeHeight(t *testing.T) { + const migrationHeight = int64(100) + + t.Run("nil gate is never below", func(t *testing.T) { + var gate *utils.LazyUpgradeHeight // disabled (resolve was nil) + if gate.Below(sdk.Context{}.WithBlockHeight(50)) { + t.Fatal("expected false for nil gate") + } + if gate.Height(sdk.Context{}.WithBlockHeight(50)) != 0 { + t.Fatal("expected height 0 for nil gate") + } + }) + + t.Run("nil resolve yields nil gate", func(t *testing.T) { + if utils.NewLazyUpgradeHeight(nil) != nil { + t.Fatal("expected nil gate when resolve is nil") + } + }) + + t.Run("gates on height and caches", func(t *testing.T) { + calls := 0 + gate := utils.NewLazyUpgradeHeight(func(sdk.Context) int64 { + calls++ + return migrationHeight + }) + + if !gate.Below(sdk.Context{}.WithBlockHeight(migrationHeight - 1)) { + t.Fatal("expected true below migration height") + } + if gate.Below(sdk.Context{}.WithBlockHeight(migrationHeight)) { + t.Fatal("expected false at migration height") + } + if gate.Below(sdk.Context{}.WithBlockHeight(migrationHeight + 10)) { + t.Fatal("expected false above migration height") + } + if calls != 1 { + t.Fatalf("expected resolver cached after first non-zero result, got %d calls", calls) + } + }) + + t.Run("non-positive height is never below", func(t *testing.T) { + gate := utils.NewLazyUpgradeHeight(func(sdk.Context) int64 { return migrationHeight }) + if gate.Below(sdk.Context{}.WithBlockHeight(0)) { + t.Fatal("expected false at height 0") + } + }) + + t.Run("re-reads until upgrade applied, then caches", func(t *testing.T) { + calls := 0 + resolved := int64(0) + gate := utils.NewLazyUpgradeHeight(func(sdk.Context) int64 { + calls++ + return resolved + }) + + // before the upgrade the resolver returns 0 and must be re-read + if gate.Below(sdk.Context{}.WithBlockHeight(50)) { + t.Fatal("expected false before upgrade applied") + } + if gate.Below(sdk.Context{}.WithBlockHeight(50)) { + t.Fatal("expected false before upgrade applied") + } + if calls != 2 { + t.Fatalf("expected resolver re-read while returning 0, got %d calls", calls) + } + + // once applied, gating engages and the value is memoized + resolved = migrationHeight + if !gate.Below(sdk.Context{}.WithBlockHeight(50)) { + t.Fatal("expected true once upgrade applied and height below it") + } + gate.Below(sdk.Context{}.WithBlockHeight(50)) + if calls != 3 { + t.Fatalf("expected resolver cached after applied, got %d calls", calls) + } + }) +} diff --git a/x/erc20/keeper/keeper.go b/x/erc20/keeper/keeper.go index f800eba584..6f9aa285b2 100644 --- a/x/erc20/keeper/keeper.go +++ b/x/erc20/keeper/keeper.go @@ -3,6 +3,7 @@ package keeper import ( "fmt" + "github.com/cosmos/evm/utils" "github.com/cosmos/evm/x/erc20/types" transferkeeper "github.com/cosmos/ibc-go/v10/modules/apps/transfer/keeper" @@ -27,6 +28,23 @@ type Keeper struct { evmKeeper types.EVMKeeper stakingKeeper types.StakingKeeper transferKeeper *transferkeeper.Keeper + + // legacyPrecompiles, when set, enables reading the pre-migration precompile + // address lists for historical heights below the migration height (see + // IsNativePrecompileAvailable / IsDynamicPrecompileAvailable). It is nil by + // default so chains that never migrated the precompile key format are + // unaffected. + legacyPrecompiles *utils.LazyUpgradeHeight +} + +// SetLegacyPrecompilesHeightResolver enables historical lookups of ERC20 +// precompile availability for state written before the precompile key-format +// migration. resolve must return the height at which this chain migrated the +// precompile store layout (0 before it is applied); below that height the legacy +// concatenated-blob keys are consulted. Passing nil disables the behavior. +func (k *Keeper) SetLegacyPrecompilesHeightResolver(resolve func(ctx sdk.Context) int64) *Keeper { + k.legacyPrecompiles = utils.NewLazyUpgradeHeight(resolve) + return k } // NewKeeper creates new instances of the erc20 Keeper diff --git a/x/erc20/keeper/legacy_precompiles_test.go b/x/erc20/keeper/legacy_precompiles_test.go new file mode 100644 index 0000000000..70712e7c71 --- /dev/null +++ b/x/erc20/keeper/legacy_precompiles_test.go @@ -0,0 +1,58 @@ +package keeper + +import ( + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" +) + +func TestLegacyBlobContains(t *testing.T) { + a := "0x1111111111111111111111111111111111111111" + b := "0x2222222222222222222222222222222222222222" + c := "0x3333333333333333333333333333333333333333" + + blob := []byte(a + b) + + t.Run("present", func(t *testing.T) { + if !legacyBlobContains(blob, common.HexToAddress(a)) { + t.Fatal("expected first address to be found") + } + if !legacyBlobContains(blob, common.HexToAddress(b)) { + t.Fatal("expected second address to be found") + } + }) + + t.Run("absent", func(t *testing.T) { + if legacyBlobContains(blob, common.HexToAddress(c)) { + t.Fatal("expected absent address to be reported missing") + } + }) + + t.Run("case-insensitive", func(t *testing.T) { + // old node may have stored a differently-cased (non-EIP-55) hex string + mixed := []byte("0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa") + if !legacyBlobContains(mixed, common.HexToAddress("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")) { + t.Fatal("expected case-insensitive address match") + } + }) + + t.Run("empty blob", func(t *testing.T) { + if legacyBlobContains(nil, common.HexToAddress(a)) { + t.Fatal("expected false for empty blob") + } + }) + + t.Run("malformed length", func(t *testing.T) { + if legacyBlobContains([]byte(a+"0x12"), common.HexToAddress(a)) { + t.Fatal("expected false for blob length not a multiple of address width") + } + }) + + t.Run("non-hex chunk is skipped", func(t *testing.T) { + garbage := []byte(strings.Repeat("z", legacyPrecompileAddrLen)) + if legacyBlobContains(garbage, common.HexToAddress(a)) { + t.Fatal("expected false for non-hex chunk") + } + }) +} diff --git a/x/erc20/keeper/precompiles.go b/x/erc20/keeper/precompiles.go index 95e40d8f0d..b5d7e1de34 100644 --- a/x/erc20/keeper/precompiles.go +++ b/x/erc20/keeper/precompiles.go @@ -115,6 +115,9 @@ func (k Keeper) GetNativePrecompiles(ctx sdk.Context) []string { } func (k Keeper) IsNativePrecompileAvailable(ctx sdk.Context, precompile common.Address) bool { + if k.legacyPrecompiles.Below(ctx) { + return k.legacyPrecompileRegistered(ctx, legacyNativePrecompilesKey, precompile) + } store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefixNativePrecompiles) return store.Has([]byte(precompile.Hex())) } @@ -155,10 +158,50 @@ func (k Keeper) GetDynamicPrecompiles(ctx sdk.Context) []string { } func (k Keeper) IsDynamicPrecompileAvailable(ctx sdk.Context, precompile common.Address) bool { + if k.legacyPrecompiles.Below(ctx) { + return k.legacyPrecompileRegistered(ctx, legacyDynamicPrecompilesKey, precompile) + } store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefixDynamicPrecompiles) return store.Has([]byte(precompile.Hex())) } +// legacyNativePrecompilesKey and legacyDynamicPrecompilesKey are the +// pre-migration store keys that held the precompile lists as a single +// concatenated blob of 42-character ASCII hex addresses. They were replaced by +// per-address keys ({0x06}/{0x07}) and deleted during the store migration, so +// they only remain in historical state below the migration height. +var ( + legacyNativePrecompilesKey = []byte("NativePrecompiles") + legacyDynamicPrecompilesKey = []byte("DynamicPrecompiles") +) + +const legacyPrecompileAddrLen = 42 // len("0x" + 40 hex chars) + +// legacyPrecompileRegistered reports whether precompile appears in the legacy +// concatenated-blob list stored under legacyKey. Addresses are compared by value +// (case-insensitive) to be robust to checksum-casing differences between how the +// old node wrote the list and the queried address. +func (k Keeper) legacyPrecompileRegistered(ctx sdk.Context, legacyKey []byte, precompile common.Address) bool { + return legacyBlobContains(ctx.KVStore(k.storeKey).Get(legacyKey), precompile) +} + +// legacyBlobContains reports whether precompile appears in a legacy +// concatenated-blob precompile list (42-character ASCII hex addresses back to +// back). A blob whose length is not a multiple of the address width is treated +// as absent rather than parsed partially. +func legacyBlobContains(blob []byte, precompile common.Address) bool { + if len(blob) == 0 || len(blob)%legacyPrecompileAddrLen != 0 { + return false + } + for i := 0; i+legacyPrecompileAddrLen <= len(blob); i += legacyPrecompileAddrLen { + hexAddr := string(blob[i : i+legacyPrecompileAddrLen]) + if common.IsHexAddress(hexAddr) && common.HexToAddress(hexAddr) == precompile { + return true + } + } + return false +} + func (k Keeper) SetDynamicPrecompile(ctx sdk.Context, precompile common.Address) { store := prefix.NewStore(ctx.KVStore(k.storeKey), types.KeyPrefixDynamicPrecompiles) store.Set([]byte(precompile.Hex()), isTrue) diff --git a/x/vm/keeper/keeper.go b/x/vm/keeper/keeper.go index c88648b237..273f61fc4c 100644 --- a/x/vm/keeper/keeper.go +++ b/x/vm/keeper/keeper.go @@ -85,6 +85,11 @@ type Keeper struct { // defaultEvmCoinInfo is the default EVM coin info used when evmCoinInfo is not initialized in the state, // mainly for historical queries. defaultEvmCoinInfo types.EvmCoinInfo + + // legacyParams, when set, enables decoding of pre-migration x/vm Params for + // historical reads below the migration height (see GetParams). It is nil by + // default so chains that never changed the Params layout are unaffected. + legacyParams *utils.LazyUpgradeHeight } // NewKeeper generates new evm module keeper @@ -221,6 +226,15 @@ func (k *Keeper) SetHooks(eh types.EvmHooks) *Keeper { return k } +// SetLegacyParamsHeightResolver enables historical decoding of pre-migration +// x/vm Params. resolve must return the height at which this chain migrated the +// Params store layout (0 before it is applied); GetParams decodes state below +// that height with the legacy layout. Passing nil disables the behavior. +func (k *Keeper) SetLegacyParamsHeightResolver(resolve func(ctx sdk.Context) int64) *Keeper { + k.legacyParams = utils.NewLazyUpgradeHeight(resolve) + 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( diff --git a/x/vm/keeper/params.go b/x/vm/keeper/params.go index b5892ad4dc..7a07875de2 100644 --- a/x/vm/keeper/params.go +++ b/x/vm/keeper/params.go @@ -20,6 +20,19 @@ func (k Keeper) GetParams(ctx sdk.Context) (params types.Params) { if bz == nil { return params } + + // Historical state written before the Params store migration uses the old + // proto field numbers; decoding it with the current schema panics (e.g. + // "wrong wireType for field HistoryServeWindow") or silently corrupts shifted + // fields, breaking read-only historical queries such as eth_call. + if k.legacyParams.Below(ctx) { + params, err := types.DecodeLegacyParams(bz) + if err != nil { + panic(err) + } + return params + } + k.cdc.MustUnmarshal(bz, ¶ms) return } diff --git a/x/vm/types/legacy_params.go b/x/vm/types/legacy_params.go new file mode 100644 index 0000000000..b2c3c0ceab --- /dev/null +++ b/x/vm/types/legacy_params.go @@ -0,0 +1,64 @@ +package types + +// legacy_params.go supports decoding x/vm Params that were written to state by +// the evmos-based cosmos/evm layout (@ b1c973f) used before chains migrated to +// the current Params proto. The field numbers shifted between the two layouts, +// so historical state (immutable IAVL below the migration height) cannot be +// decoded with the generated Params.Unmarshal — most visibly it panics with +// "wrong wireType = 2 for field HistoryServeWindow", because old field 10 +// (active_static_precompiles, length-delimited) collides with new field 10 +// (history_serve_window, varint). +// +// This is only used on read paths for historical heights (e.g. eth_call); the +// on-chain migration already re-encoded the live Params at the upgrade height. +// +// Old → New +// 1 evm_denom → 1 +// 4 extra_eips → 4 +// 5 chain_config → removed +// 6 allow_unprotected_txs → removed +// 8 evm_channels → 7 +// 9 access_control → 8 +// 10 active_static_precompiles → 9 + +import ( + proto "github.com/cosmos/gogoproto/proto" +) + +// legacyParams mirrors the pre-migration x/vm Params binary layout. Fields 5 +// (chain_config) and 6 (allow_unprotected_txs) are intentionally omitted — +// proto.Unmarshal skips them as unknown fields. It is only ever read, never +// written back to the store. +type legacyParams struct { + EvmDenom string `protobuf:"bytes,1,opt,name=evm_denom,json=evmDenom,proto3"` + ExtraEIPs []int64 `protobuf:"varint,4,rep,packed,name=extra_eips,json=extraEips,proto3"` + EVMChannels []string `protobuf:"bytes,8,rep,name=evm_channels,json=evmChannels,proto3"` + AccessControl AccessControl `protobuf:"bytes,9,opt,name=access_control,json=accessControl,proto3"` + ActiveStaticPrecompiles []string `protobuf:"bytes,10,rep,name=active_static_precompiles,json=activeStaticPrecompiles,proto3"` +} + +func (m *legacyParams) Reset() { *m = legacyParams{} } +func (m *legacyParams) String() string { return proto.CompactTextString(m) } +func (m *legacyParams) ProtoMessage() {} + +// DecodeLegacyParams decodes pre-migration x/vm Params bytes and translates them +// into the current Params layout. +// +// HistoryServeWindow and ExtendedDenomOptions have no counterpart in the old +// schema and are left at their zero values. A zero HistoryServeWindow is +// harmless on read paths: keeper helpers fall back to DefaultHistoryServeWindow +// when the parameter is zero. +func DecodeLegacyParams(raw []byte) (Params, error) { + var old legacyParams + if err := proto.Unmarshal(raw, &old); err != nil { + return Params{}, err + } + + return Params{ + EvmDenom: old.EvmDenom, + ExtraEIPs: old.ExtraEIPs, + EVMChannels: old.EVMChannels, + AccessControl: old.AccessControl, + ActiveStaticPrecompiles: old.ActiveStaticPrecompiles, + }, nil +} diff --git a/x/vm/types/legacy_params_test.go b/x/vm/types/legacy_params_test.go new file mode 100644 index 0000000000..122a59431a --- /dev/null +++ b/x/vm/types/legacy_params_test.go @@ -0,0 +1,99 @@ +package types + +import ( + "reflect" + "strings" + "testing" + + proto "github.com/cosmos/gogoproto/proto" +) + +// marshalLegacyParams encodes Params using the pre-migration field numbers, i.e. +// exactly the bytes a pre-v1.6.0 node wrote to historical state. +func marshalLegacyParams(t *testing.T, old *legacyParams) []byte { + t.Helper() + raw, err := proto.Marshal(old) + if err != nil { + t.Fatalf("marshal legacy params: %v", err) + } + return raw +} + +// TestCurrentSchemaFailsOnLegacyParams reproduces the mainnet failure: decoding +// pre-migration Params bytes with the current generated schema trips over old +// field 10 (active_static_precompiles, length-delimited) landing on new field 10 +// (history_serve_window, varint). +func TestCurrentSchemaFailsOnLegacyParams(t *testing.T) { + // evm_channels intentionally empty so decoding reaches field 10 and fails + // there, matching the reported "-32000 ... wrong wireType = 2 for field + // HistoryServeWindow". + raw := marshalLegacyParams(t, &legacyParams{ + EvmDenom: "atac", + ExtraEIPs: []int64{3855}, + ActiveStaticPrecompiles: []string{ + "0x0000000000000000000000000000000000000800", + "0x0000000000000000000000000000000000000801", + }, + }) + + var p Params + err := proto.Unmarshal(raw, &p) + if err == nil { + t.Fatal("expected decode of legacy params with current schema to fail, got nil error") + } + if !strings.Contains(err.Error(), "HistoryServeWindow") { + t.Fatalf("expected HistoryServeWindow wireType error, got: %v", err) + } +} + +// TestDecodeLegacyParams verifies the shifted fields are mapped to their current +// positions and the new-only fields are left at zero. +func TestDecodeLegacyParams(t *testing.T) { + want := legacyParams{ + EvmDenom: "atac", + ExtraEIPs: []int64{2929, 3855}, + EVMChannels: []string{"channel-0", "channel-3"}, + AccessControl: AccessControl{ + Create: AccessControlType{AccessType: AccessTypePermissionless}, + Call: AccessControlType{ + AccessType: AccessTypeRestricted, + AccessControlList: []string{"0x1111111111111111111111111111111111111111"}, + }, + }, + ActiveStaticPrecompiles: []string{ + "0x0000000000000000000000000000000000000800", + "0x0000000000000000000000000000000000000801", + }, + } + + raw := marshalLegacyParams(t, &want) + + got, err := DecodeLegacyParams(raw) + if err != nil { + t.Fatalf("DecodeLegacyParams: %v", err) + } + + if got.EvmDenom != want.EvmDenom { + t.Errorf("EvmDenom: got %q, want %q", got.EvmDenom, want.EvmDenom) + } + if !reflect.DeepEqual(got.ExtraEIPs, want.ExtraEIPs) { + t.Errorf("ExtraEIPs: got %v, want %v", got.ExtraEIPs, want.ExtraEIPs) + } + if !reflect.DeepEqual(got.EVMChannels, want.EVMChannels) { + t.Errorf("EVMChannels: got %v, want %v", got.EVMChannels, want.EVMChannels) + } + if !reflect.DeepEqual(got.AccessControl, want.AccessControl) { + t.Errorf("AccessControl: got %+v, want %+v", got.AccessControl, want.AccessControl) + } + if !reflect.DeepEqual(got.ActiveStaticPrecompiles, want.ActiveStaticPrecompiles) { + t.Errorf("ActiveStaticPrecompiles: got %v, want %v", got.ActiveStaticPrecompiles, want.ActiveStaticPrecompiles) + } + + // new-only fields have no legacy counterpart and must stay zero + if got.HistoryServeWindow != 0 { + t.Errorf("HistoryServeWindow: got %d, want 0", got.HistoryServeWindow) + } + if got.ExtendedDenomOptions != nil { + t.Errorf("ExtendedDenomOptions: got %+v, want nil", got.ExtendedDenomOptions) + } +} From 436c16e01aac33a86334870f26f804efe162fed4 Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Thu, 2 Jul 2026 20:35:51 +0700 Subject: [PATCH 2/4] fix(vm,erc20): warm legacy-decode height cache from live state The migration height was resolved lazily from the query context. For a historical eth_call below the migration height, that context predates the upgrade, so GetDoneHeight returned 0 and the legacy-decode paths were skipped (params happened to work only because GetParams warms its cache every block via BeginBlock; erc20 precompile-availability is not hit every block, so its cache stayed cold and gTAC.balanceOf at old heights returned 0 instead of the real balance). Add PrimeLegacyParamsHeight / PrimeLegacyPrecompilesHeight to warm both gates from a current-height context; the app calls them in BeginBlocker. Once the upgrade is applied the height is cached and reused by historical reads. Verified end-to-end (v1.0.4 -> v1.6.0) on real pre-upgrade state. --- x/erc20/keeper/keeper.go | 9 +++++++++ x/vm/keeper/keeper.go | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/x/erc20/keeper/keeper.go b/x/erc20/keeper/keeper.go index 6f9aa285b2..9ea3dc3833 100644 --- a/x/erc20/keeper/keeper.go +++ b/x/erc20/keeper/keeper.go @@ -47,6 +47,15 @@ func (k *Keeper) SetLegacyPrecompilesHeightResolver(resolve func(ctx sdk.Context return k } +// PrimeLegacyPrecompilesHeight resolves and caches the migration height from the +// given (current-height) context. Call it from a per-block hook so the cache is +// warmed from live state where the applied upgrade height is visible; unlike +// GetParams, precompile-availability checks are not hit every block, so the +// cache would otherwise stay cold until a historical query resolves it as 0. +func (k Keeper) PrimeLegacyPrecompilesHeight(ctx sdk.Context) { + k.legacyPrecompiles.Height(ctx) +} + // NewKeeper creates new instances of the erc20 Keeper func NewKeeper( storeKey storetypes.StoreKey, diff --git a/x/vm/keeper/keeper.go b/x/vm/keeper/keeper.go index 273f61fc4c..7010249b5e 100644 --- a/x/vm/keeper/keeper.go +++ b/x/vm/keeper/keeper.go @@ -235,6 +235,15 @@ func (k *Keeper) SetLegacyParamsHeightResolver(resolve func(ctx sdk.Context) int return k } +// PrimeLegacyParamsHeight resolves and caches the migration height from the +// given (current-height) context. Call it from a per-block hook so the cache is +// warmed from live state where the applied upgrade height is visible; otherwise +// the first read could be a historical query whose context predates the upgrade +// and would resolve the height as 0. +func (k Keeper) PrimeLegacyParamsHeight(ctx sdk.Context) { + k.legacyParams.Height(ctx) +} + // PostTxProcessing delegates the call to the hooks. // If no hook has been registered, this function returns with a `nil` error func (k *Keeper) PostTxProcessing( From 9d115d6c9b38999861f52fdee015065077a11fc2 Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Thu, 2 Jul 2026 20:55:31 +0700 Subject: [PATCH 3/4] refactor(vm): warm legacy-decode caches in x/vm BeginBlock Move the per-block cache warming into the x/vm module's BeginBlock instead of relying on the host app's BeginBlocker. BeginBlock now primes its own legacy Params height and, via the Erc20Keeper interface, the erc20 precompile-availability height. Keeps the whole historical-decode fix inside the fork; the host app no longer needs a warming hook. --- x/vm/keeper/abci.go | 7 +++++++ x/vm/types/interfaces.go | 4 ++++ x/vm/types/mocks/Erc20Keeper.go | 5 +++++ 3 files changed, 16 insertions(+) diff --git a/x/vm/keeper/abci.go b/x/vm/keeper/abci.go index 558c230bef..0ae1965cf3 100644 --- a/x/vm/keeper/abci.go +++ b/x/vm/keeper/abci.go @@ -14,6 +14,13 @@ import ( func (k *Keeper) BeginBlock(ctx sdk.Context) error { logger := ctx.Logger().With("begin_block", "evm") + // Warm the historical legacy-decode height caches from live state, where the + // applied migration height is visible. Without this the first read could be a + // historical query whose context predates the upgrade and resolves the height + // as 0, skipping the legacy-decode paths. + k.PrimeLegacyParamsHeight(ctx) + k.erc20Keeper.PrimeLegacyPrecompilesHeight(ctx) + // Base fee is already set on FeeMarket BeginBlock // that runs before this one // We emit this event on the EVM and FeeMarket modules diff --git a/x/vm/types/interfaces.go b/x/vm/types/interfaces.go index 75d3586733..2bcec92a04 100644 --- a/x/vm/types/interfaces.go +++ b/x/vm/types/interfaces.go @@ -71,6 +71,10 @@ type FeeMarketKeeper interface { // Erc20Keeper defines the expected interface needed to instantiate ERC20 precompiles. type Erc20Keeper interface { GetERC20PrecompileInstance(ctx sdk.Context, address common.Address) (contract vm.PrecompiledContract, found bool, err error) + // PrimeLegacyPrecompilesHeight warms the historical precompile-availability + // height cache from the current context. Called from x/vm BeginBlock so the + // cache is ready for historical eth_call below the migration height. + PrimeLegacyPrecompilesHeight(ctx sdk.Context) } // EvmHooks event hooks for evm tx processing diff --git a/x/vm/types/mocks/Erc20Keeper.go b/x/vm/types/mocks/Erc20Keeper.go index a946b09bc8..79fe6e077a 100644 --- a/x/vm/types/mocks/Erc20Keeper.go +++ b/x/vm/types/mocks/Erc20Keeper.go @@ -53,6 +53,11 @@ func (_m *Erc20Keeper) GetERC20PrecompileInstance(ctx types.Context, address com return r0, r1, r2 } +// PrimeLegacyPrecompilesHeight provides a mock function with given fields: ctx +func (_m *Erc20Keeper) PrimeLegacyPrecompilesHeight(ctx types.Context) { + _m.Called(ctx) +} + // NewErc20Keeper creates a new instance of Erc20Keeper. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. // The first argument is typically a *testing.T value. func NewErc20Keeper(t interface { From 5796b41535ddedc9200f2bcc69455c538ca1b603 Mon Sep 17 00:00:00 2001 From: Aleksei Kokinos Date: Thu, 2 Jul 2026 21:16:15 +0700 Subject: [PATCH 4/4] fix(vm): guard nil erc20Keeper in BeginBlock cache warming erc20Keeper is optional in the vm keeper (GetPrecompileInstance guards nil), so keep BeginBlock consistent and nil-safe for chains that wire the vm keeper without x/erc20. --- x/vm/keeper/abci.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/x/vm/keeper/abci.go b/x/vm/keeper/abci.go index 0ae1965cf3..8438e027f1 100644 --- a/x/vm/keeper/abci.go +++ b/x/vm/keeper/abci.go @@ -19,7 +19,9 @@ func (k *Keeper) BeginBlock(ctx sdk.Context) error { // historical query whose context predates the upgrade and resolves the height // as 0, skipping the legacy-decode paths. k.PrimeLegacyParamsHeight(ctx) - k.erc20Keeper.PrimeLegacyPrecompilesHeight(ctx) + if k.erc20Keeper != nil { + k.erc20Keeper.PrimeLegacyPrecompilesHeight(ctx) + } // Base fee is already set on FeeMarket BeginBlock // that runs before this one