Skip to content
Open
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
6 changes: 3 additions & 3 deletions cmd/util/cmd/checkpoint-collect-stats/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ import (

"github.com/onflow/flow-go/cmd/util/ledger/reporters"
"github.com/onflow/flow-go/cmd/util/ledger/util"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/emulator/state"
"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/ledger"
"github.com/onflow/flow-go/ledger/common/pathfinder"
"github.com/onflow/flow-go/ledger/complete"
Expand Down Expand Up @@ -465,9 +465,9 @@ func getRegisterType(key ledger.Key) string {
return "account storage ID"
case state.CodesStorageIDKey:
return "code storage ID"
case handler.BlockStoreLatestBlockKey:
case environment.BlockStoreLatestBlockKey:
return "latest block"
case handler.BlockStoreLatestBlockProposalKey:
case environment.BlockStoreLatestBlockProposalKey:
return "latest block proposal"
}

Expand Down
2 changes: 2 additions & 0 deletions fvm/environment/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ type Environment interface {
// Reset resets all stateful environment modules (e.g., ContractUpdater,
// EventEmitter) to initial state.
Reset()

EVMBlockStore
}

// ReusableCadenceRuntime is a wrapper around the cadence runtime and environment that
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package handler
package environment

import (
"encoding/binary"
Expand All @@ -7,7 +7,6 @@ import (

gethCommon "github.com/ethereum/go-ethereum/common"

"github.com/onflow/flow-go/fvm/evm/types"
"github.com/onflow/flow-go/model/flow"
)

Expand Down Expand Up @@ -41,7 +40,7 @@ func IsBlockHashListMetaKey(id flow.RegisterID) bool {
// smaller fixed size buckets to minimize the
// number of bytes read and written during set/get operations.
type BlockHashList struct {
backend types.BackendStorage
backend ValueStore
rootAddress flow.Address

// cached meta data
Expand All @@ -55,7 +54,7 @@ type BlockHashList struct {
// It tries to load the metadata from the backend
// and if not exist it creates one
func NewBlockHashList(
backend types.BackendStorage,
backend ValueStore,
rootAddress flow.Address,
capacity int,
) (*BlockHashList, error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
package handler_test
package environment_test

import (
"testing"

gethCommon "github.com/ethereum/go-ethereum/common"
"github.com/stretchr/testify/require"

"github.com/onflow/flow-go/fvm/evm/handler"
"github.com/onflow/flow-go/fvm/environment"
"github.com/onflow/flow-go/fvm/evm/testutils"
"github.com/onflow/flow-go/model/flow"
)

func TestBlockHashList(t *testing.T) {
testutils.RunWithTestBackend(t, func(backend *testutils.TestBackend) {
testutils.RunWithTestBackend(t, flow.Testnet, func(backend *testutils.TestBackend) {
testutils.RunWithTestFlowEVMRootAddress(t, backend, func(root flow.Address) {
capacity := 256
bhl, err := handler.NewBlockHashList(backend, root, capacity)
bhl, err := environment.NewBlockHashList(backend, root, capacity)
require.NoError(t, err)
require.True(t, bhl.IsEmpty())

Expand Down Expand Up @@ -75,7 +75,7 @@ func TestBlockHashList(t *testing.T) {
require.Equal(t, gethCommon.Hash{byte(capacity + 2)}, h)

// construct a new one and check
bhl, err = handler.NewBlockHashList(backend, root, capacity)
bhl, err = environment.NewBlockHashList(backend, root, capacity)
require.NoError(t, err)
require.False(t, bhl.IsEmpty())

Expand Down
297 changes: 297 additions & 0 deletions fvm/environment/evm_block_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,297 @@
package environment

import (
"fmt"
"time"

gethCommon "github.com/ethereum/go-ethereum/common"

"github.com/onflow/flow-go/fvm/evm/types"
"github.com/onflow/flow-go/model/flow"
)

// BlockStore stores the chain of blocks
type EVMBlockStore interface {
// LatestBlock returns the latest appended block
LatestBlock() (*types.Block, error)

// BlockHash returns the hash of the block at the given height
BlockHash(height uint64) (gethCommon.Hash, error)

// BlockProposal returns the active block proposal
BlockProposal() (*types.BlockProposal, error)

// StageBlockProposal updates the in-memory block proposal without writing to
// storage. Persistence happens at transaction end via FlushBlockProposal.
StageBlockProposal(*types.BlockProposal)

// CommitBlockProposal commits the block proposal and update the chain of blocks
CommitBlockProposal(*types.BlockProposal) error
}

const (
BlockHashListCapacity = 256
BlockStoreLatestBlockKey = "LatestBlock"
BlockStoreLatestBlockProposalKey = "LatestBlockProposal"
)

// BlockStore manages EVM block storage and block proposal lifecycle during Flow block execution.
//
// Storage Keys:
// - LatestBlock: The last finalized EVM block. Updated only at CommitBlockProposal().
// - LatestBlockProposal: The in-progress EVM block accumulating transactions.
// Its parent hash must equal hash(LatestBlock) and height must equal LatestBlock.Height + 1.
//
// Each Cadence transaction creates a new BlockStore instance with an empty cache.
// The cache avoids repeated storage reads within a single Cadence transaction.
//
// Flow Block K Execution:
//
// ├── Cadence tx 1 (succeed)
// │ ├── EVM Tx A
// │ │ ├── BlockProposal()
// │ │ │ ├── cache miss
// │ │ │ ├── read LatestBlockProposal from storage
// │ │ │ │ └── (if empty) read LatestBlock from storage (for parent hash, height)
// │ │ │ └── cache it
// │ │ └── StageBlockProposal() → update cache
// │ ├── EVM Tx B
// │ │ ├── BlockProposal() → cache hit
// │ │ └── StageBlockProposal() → update cache
// │ └── [tx end]
// │ └── FlushBlockProposal() → write LatestBlockProposal, cache = nil
// │
// ├── Cadence tx 2 (failed)
// │ ├── EVM Tx C
// │ │ ├── BlockProposal()
// │ │ │ ├── cache miss
// │ │ │ └── read LatestBlockProposal from storage → cache it
// │ │ └── StageBlockProposal() → update cache
// │ ├── EVM Tx D
// │ │ ├── BlockProposal() → cache hit
// │ │ └── StageBlockProposal() → update cache
// │ └── [tx fail/revert]
// │ └── Reset() → cache = nil, storage unchanged
// │
// └── System chunk tx (last)
// └── heartbeat()
// └── CommitBlockProposal()
// ├── write LatestBlock
// ├── write new LatestBlockProposal (for next flow block)
// └── cache = nil
type BlockStore struct {
chainID flow.ChainID
storage ValueStore
blockInfo BlockInfo
randGen RandomGenerator
rootAddress flow.Address
cached *types.BlockProposal
}
Comment on lines 82 to 89
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

BlockHashList still is not cached per transaction.

BlockStore only keeps cached *types.BlockProposal; getBlockHashList() reconstructs the hash list from storage on every BlockHash() / CommitBlockProposal() path. Repeated BLOCKHASH access inside one Cadence transaction will therefore stay on the metered storage path, which leaves part of the targeted optimization on the table.

Possible direction
 type BlockStore struct {
 	chainID     flow.ChainID
 	storage     ValueStore
 	blockInfo   BlockInfo
 	randGen     RandomGenerator
 	rootAddress flow.Address
 	cached      *types.BlockProposal
+	cachedBlockHashList *BlockHashList
 }
@@
 func (bs *BlockStore) getBlockHashList() (*BlockHashList, error) {
+	if bs.cachedBlockHashList != nil {
+		return bs.cachedBlockHashList, nil
+	}
+
 	bhl, err := NewBlockHashList(bs.storage, bs.rootAddress, BlockHashListCapacity)
 	if err != nil {
 		return nil, err
 	}
@@
 	if bhl.IsEmpty() {
 		err = bhl.Push(
 			types.GenesisBlock(bs.chainID).Height,
 			types.GenesisBlockHash(bs.chainID),
 		)
 		if err != nil {
 			return nil, err
 		}
 	}
 
+	bs.cachedBlockHashList = bhl
 	return bhl, nil
 }

Also applies to: 224-240

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fvm/environment/evm_block_store.go` around lines 46 - 53, BlockStore
currently only caches a BlockProposal (cached) but not the reconstructed
block-hash list, so getBlockHashList() rebuilds it from storage on every
BLOCKHASH access; add a cached block-hash list field (e.g., cachedHashList) to
BlockStore, modify getBlockHashList() to return the cached list when present and
populate it the first time it is built, and update/invalidate that cache in
CommitBlockProposal() and anywhere the cached BlockProposal (cached) is set or
cleared (so BlockHash(), CommitBlockProposal(), and any setter that touches
cached use the cachedHashList instead of always reconstructing).


var _ EVMBlockStore = &BlockStore{}

// NewBlockStore constructs a new block store
func NewBlockStore(
chainID flow.ChainID,
storage ValueStore,
blockInfo BlockInfo,
randGen RandomGenerator,
rootAddress flow.Address,
) *BlockStore {
return &BlockStore{
chainID: chainID,
storage: storage,
blockInfo: blockInfo,
randGen: randGen,
rootAddress: rootAddress,
}
}

// BlockProposal returns the block proposal to be updated by the handler
func (bs *BlockStore) BlockProposal() (*types.BlockProposal, error) {
if bs.cached != nil {
return bs.cached, nil
}
// first fetch it from the storage
data, err := bs.storage.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockProposalKey))
if err != nil {
return nil, err
}
if len(data) != 0 {
bp, err := types.NewBlockProposalFromBytes(data)
if err != nil {
return nil, err
}
bs.cached = bp
return bp, nil
}
bp, err := bs.constructBlockProposal()
if err != nil {
return nil, err
}
bs.cached = bp
return bp, nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func (bs *BlockStore) constructBlockProposal() (*types.BlockProposal, error) {
// if available construct a new one
cadenceHeight, err := bs.blockInfo.GetCurrentBlockHeight()
if err != nil {
return nil, err
}

cadenceBlock, found, err := bs.blockInfo.GetBlockAtHeight(cadenceHeight)
if err != nil {
return nil, err
}
if !found {
return nil, fmt.Errorf("cadence block not found")
}

lastExecutedBlock, err := bs.LatestBlock()
if err != nil {
return nil, err
}

parentHash, err := lastExecutedBlock.Hash()
if err != nil {
return nil, err
}

// cadence block timestamp is unix nanoseconds but evm blocks
// expect timestamps in unix seconds so we convert here
timestamp := uint64(cadenceBlock.Timestamp / int64(time.Second))

// read a random value for block proposal
prevrandao := gethCommon.Hash{}
err = bs.randGen.ReadRandom(prevrandao[:])
if err != nil {
return nil, err
}

blockProposal := types.NewBlockProposal(
parentHash,
lastExecutedBlock.Height+1,
timestamp,
lastExecutedBlock.TotalSupply,
prevrandao,
)

return blockProposal, nil
}

// UpdateBlockProposal updates the block proposal
func (bs *BlockStore) updateBlockProposal(bp *types.BlockProposal) error {
blockProposalBytes, err := bp.ToBytes()
if err != nil {
return types.NewFatalError(err)
}

return bs.storage.SetValue(
bs.rootAddress[:],
[]byte(BlockStoreLatestBlockProposalKey),
blockProposalBytes,
)
}

// CommitBlockProposal commits the block proposal to the chain
func (bs *BlockStore) CommitBlockProposal(bp *types.BlockProposal) error {
bp.PopulateRoots()

blockBytes, err := bp.Block.ToBytes()
if err != nil {
return types.NewFatalError(err)
}

err = bs.storage.SetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey), blockBytes)
if err != nil {
return err
}

hash, err := bp.Block.Hash()
if err != nil {
return err
}

bhl, err := bs.getBlockHashList()
if err != nil {
return err
}
err = bhl.Push(bp.Block.Height, hash)
if err != nil {
return err
}

// Construct and store the new block proposal eagerly to maintain
// state compatibility with the previous implementation.
newBP, err := bs.constructBlockProposal()
Copy link
Copy Markdown
Member

@zhangchiqing zhangchiqing Apr 22, 2026

Choose a reason for hiding this comment

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

I decided to revert my changes, and keep as is. The optimization of the lazy construction of next block proposal would be incompatible with previous implementation which makes it harder to verify the correctness of this PR.

if err != nil {
return err
}
err = bs.updateBlockProposal(newBP)
if err != nil {
return err
}
bs.cached = nil
return nil
}

// LatestBlock returns the latest executed block
func (bs *BlockStore) LatestBlock() (*types.Block, error) {
data, err := bs.storage.GetValue(bs.rootAddress[:], []byte(BlockStoreLatestBlockKey))
if err != nil {
return nil, err
}
if len(data) == 0 {
return types.GenesisBlock(bs.chainID), nil
}
return types.NewBlockFromBytes(data)
}

// BlockHash returns the block hash for the last x blocks
func (bs *BlockStore) BlockHash(height uint64) (gethCommon.Hash, error) {
bhl, err := bs.getBlockHashList()
if err != nil {
return gethCommon.Hash{}, err
}
_, hash, err := bhl.BlockHashByHeight(height)
return hash, err
}

func (bs *BlockStore) getBlockHashList() (*BlockHashList, error) {
bhl, err := NewBlockHashList(bs.storage, bs.rootAddress, BlockHashListCapacity)
if err != nil {
return nil, err
}

if bhl.IsEmpty() {
err = bhl.Push(
types.GenesisBlock(bs.chainID).Height,
types.GenesisBlockHash(bs.chainID),
)
if err != nil {
return nil, err
}
}

return bhl, nil
}

func (bs *BlockStore) ResetBlockProposal() {
bs.cached = nil
}

func (bs *BlockStore) StageBlockProposal(bp *types.BlockProposal) {
bs.cached = bp
}

func (bs *BlockStore) FlushBlockProposal() error {
if bs.cached == nil {
return nil
}
err := bs.updateBlockProposal(bs.cached)
if err != nil {
return err
}
return nil
}
Loading
Loading