Skip to content
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
63 changes: 63 additions & 0 deletions utils/lazy_upgrade_height.go
Original file line number Diff line number Diff line change
@@ -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
}
87 changes: 87 additions & 0 deletions utils/lazy_upgrade_height_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
27 changes: 27 additions & 0 deletions x/erc20/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -27,6 +28,32 @@ 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
}

// 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
Expand Down
58 changes: 58 additions & 0 deletions x/erc20/keeper/legacy_precompiles_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
43 changes: 43 additions & 0 deletions x/erc20/keeper/precompiles.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions x/vm/keeper/abci.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ 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)
if k.erc20Keeper != nil {
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
Expand Down
23 changes: 23 additions & 0 deletions x/vm/keeper/keeper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -221,6 +226,24 @@ 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
}

// 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(
Expand Down
13 changes: 13 additions & 0 deletions x/vm/keeper/params.go
Original file line number Diff line number Diff line change
Expand Up @@ -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, &params)
return
}
Expand Down
4 changes: 4 additions & 0 deletions x/vm/types/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading