Skip to content

Cache EVM block proposal and defer storage writes to commit#8491

Open
holyfuchs wants to merge 11 commits intomasterfrom
holyfuchs/6958-evm-optimize-block-formation
Open

Cache EVM block proposal and defer storage writes to commit#8491
holyfuchs wants to merge 11 commits intomasterfrom
holyfuchs/6958-evm-optimize-block-formation

Conversation

@holyfuchs
Copy link
Copy Markdown
Member

@holyfuchs holyfuchs commented Mar 17, 2026

closes: #6958

Problem

Each EVM operation within a Cadence transaction (e.g. EVM.run) was reading and writing the EVM block proposal to storage on every call. For a Cadence transaction executing N EVM operations, this resulted in N redundant storage round-trips for the same block proposal.

Changes

The BlockStore has been moved from the EVM layer (fvm/evm/handler) into the FVM environment (fvm/environment) as EVMBlockStore. Since it now lives on the per-transaction environment.Environment, it can own its own caching, flushing, and resetting — no separate BlockProposalCache indirection is needed.

The block proposal is cached in-memory for the duration of a Cadence transaction and persisted exactly once at transaction end via FlushBlockProposal. On transaction failure, ResetBlockProposal discards any staged but unflushed changes. BlockHashList was moved to fvm/environment as well for the same reasons.

The EVM side accesses block proposal operations through the backends.Backend interface, which now composes EVMBlockStore directly.

Benchmarks (Computation Cost)

Transaction Deployed Master This PR vs Deployed vs Master
53d2d816 (mainnet) 6,862 4,912 4,554 -33.6% -7.3%
2f52c226 (mainnet) 424 294 255 -39.8% -13.3%
c5a7c959 (testnet, many EVM calls) 793 697 459 -42.1% -34.1%

Summary by CodeRabbit

  • Refactor

    • Modularized EVM block handling by introducing a dedicated block-store abstraction and clearer staging/flush/commit semantics for block proposals; backend interfaces updated to support these capabilities.
  • Tests

    • Updated test scaffolding and mocks to exercise the new block-store behavior; small test metric adjustment (dry-run computation measurement changed by 1).

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 17, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Environment now requires EVM block-store capabilities. Block-store implementation and lifecycle (stage/flush/commit/reset) moved into fvm/environment; backends and handlers updated to delegate block operations to the embedded EVMBlockStore. Tests and mocks updated accordingly.

Changes

Cohort / File(s) Summary
Core interfaces
fvm/environment/env.go, fvm/evm/backends/backend.go
Environment now embeds EVMBlockStore. Backend likewise embeds environment.EVMBlockStore, changing required method sets.
Environment block store implementation
fvm/environment/evm_block_store.go, fvm/environment/evm_block_hash_list.go, fvm/environment/evm_block_hash_list_test.go
Added EVMBlockStore interface, refactored BlockStore to use injected ValueStore/BlockInfo/RandomGenerator, added caching and staged proposal lifecycle (StageBlockProposal + FlushBlockProposal), added NoEVMBlockStore. Moved/renamed block-hash-list into environment pkg and adjusted tests.
Facade & mocks
fvm/environment/facade_env.go, fvm/environment/mock/evm_block_store.go, fvm/environment/mock/environment.go
facadeEnvironment gained EVMBlockStore field; delegated block operations implemented and integrated into flush/reset flows. Generated mock for EVMBlockStore and updated environment mock with new block methods and type-alias fixes.
EVM backend wrappers & handler adapters
fvm/evm/backends/wrappedEnv.go, fvm/evm/handler/handler.go, fvm/evm/handler/precompiles.go
WrappedEnvironment adds passthrough block methods mapping errors. ContractHandler removed direct BlockStore dependency and now relies on backends.Backend for block ops; precompile/provider functions updated to accept backends.Backend.
Test/testutils changes
fvm/evm/testutils/backend.go, fvm/evm/testutils/handler.go, fvm/evm/testutils/offchain.go
Test backend now provides TestBlockStore implementing EVMBlockStore. Test helpers updated to remove explicit BlockStore construction and to accept backends.Backend.
Offchain/storage & sync migration
fvm/evm/offchain/..., fvm/evm/offchain/storage/*, fvm/evm/offchain/sync/replay.go
Replaced uses of types.BackendStorage with backends.BackendStorage across offchain blocks, providers, ephemeral/readonly storages, and replay code.
EVM types cleanup
fvm/evm/types/handler.go
Removed legacy exported BlockStore interface (methods moved to environment.EVMBlockStore).
Tests & benchmarks
fvm/environment/evm_block_store_test.go, fvm/environment/evm_block_store_benchmark_test.go, fvm/evm/evm_test.go, fvm/fvm_test.go, fvm/evm/handler/handler_test.go
Tests updated to new constructor/signatures, package names switched to environment_test where appropriate. Block-proposal flow in tests changed to StageBlockProposal + FlushBlockProposal. Minor test expectation adjustment in evm_test.go.
Command utility key refactor
cmd/util/cmd/checkpoint-collect-stats/cmd.go
Switched ledger key constants to use fvm/environment block-store keys instead of handler pkg.
Autogenerated mocks added
module/state_synchronization/indexer/extended/mock/index_processor.go
Added new generic mock for IndexProcessor (test helper scaffolding).

Sequence Diagram(s)

sequenceDiagram
    rect rgba(200,200,255,0.5)
    participant EVM_Handler as EVM Handler
    participant Env as Environment / EVMBlockStore
    participant Store as ValueStore (persistent)
    end

    EVM_Handler->>Env: StageBlockProposal(bp)  -- stage in-memory
    note right of Env: cached proposal set (no write)

    EVM_Handler->>Env: StageBlockProposal(bp2)
    note right of Env: cached proposal updated

    EVM_Handler->>Env: FlushBlockProposal()
    Env->>Store: persist proposal bytes (updateBlockProposal)
    Store-->>Env: ack
    Env-->>EVM_Handler: flush result
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

Suggested labels

Performance, Flow EVM, Improvement

Suggested reviewers

  • m-Peter
  • janezpodhostnik

Poem

🐇 hopping bytes and hashes bright,
I stage proposals out of sight.
Flush and commit with gentle thump,
No repeated writes — just one big jump!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Cache EVM block proposal and defer storage writes to commit' accurately summarizes the main change: implementing in-memory caching of block proposal with deferred persistence until commit.
Linked Issues check ✅ Passed All coding objectives from #6958 are met: block proposal/blockhashlist are cached in-memory per Cadence transaction [env.go, facade_env.go, evm_block_store.go], storage writes deferred to commit phase via FlushBlockProposal [facade_env.go, evm_block_store.go], and staged changes reset on failure via ResetBlockProposal [facade_env.go, evm_block_store.go].
Out of Scope Changes check ✅ Passed All changes align with the block-proposal caching optimization; interface restructuring (BlockStore→EVMBlockStore, Backend embedding, handler refactoring) and mock updates directly support the per-transaction caching implementation with no unrelated additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch holyfuchs/6958-evm-optimize-block-formation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Mar 17, 2026

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

@blacksmith-sh

This comment has been minimized.

@holyfuchs holyfuchs force-pushed the holyfuchs/6958-evm-optimize-block-formation branch from 2f6e450 to 35ac8b4 Compare March 18, 2026 10:18
@holyfuchs holyfuchs marked this pull request as ready for review March 23, 2026 16:25
@holyfuchs holyfuchs requested a review from a team as a code owner March 23, 2026 16:25
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
fvm/evm/backends/wrappedEnv.go (1)

155-158: Consider wrapping error with handleEnvironmentError for consistency.

Other error-returning methods in this file (e.g., GetCurrentBlockHeight, ReadRandom, GenerateUUID) wrap their errors with handleEnvironmentError. This method returns the error directly without wrapping. While the underlying flusher may already return appropriately typed errors, inconsistent error handling patterns could lead to unexpected error propagation behavior.

♻️ Proposed fix for error handling consistency
 // FlushBlockProposal calls the registered flusher to persist the cached proposal.
 func (we *WrappedEnvironment) FlushBlockProposal() error {
-	return we.env.FlushBlockProposal()
+	return handleEnvironmentError(we.env.FlushBlockProposal())
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fvm/evm/backends/wrappedEnv.go` around lines 155 - 158, The
FlushBlockProposal method returns the underlying error directly; update it to
wrap the returned error using handleEnvironmentError for consistency with other
methods. Locate WrappedEnvironment.FlushBlockProposal (currently returning
we.env.FlushBlockProposal()) and change it to capture the error from
we.env.FlushBlockProposal(), and if non-nil pass it into handleEnvironmentError
with the same identifying context used elsewhere (e.g., the method name
"FlushBlockProposal") before returning; follow the exact wrapping pattern used
by GetCurrentBlockHeight/ReadRandom/GenerateUUID to keep behavior consistent.
fvm/evm/handler/blockstore.go (1)

122-127: Flusher is re-registered on every StageBlockProposal call.

SetBlockProposalFlusher is called each time StageBlockProposal is invoked. While functionally correct (the cache simply overwrites the previous flusher), this is redundant after the first call within a transaction. Consider tracking whether the flusher has already been registered to avoid unnecessary closure allocations.

That said, this is a minor optimization and may not be worth the added complexity if StageBlockProposal is called infrequently per transaction.

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

In `@fvm/evm/handler/blockstore.go` around lines 122 - 127, StageBlockProposal
currently calls bs.backend.SetBlockProposalFlusher on every invocation, causing
redundant flusher re-registration and closure allocations; add a boolean field
on BlockStore (e.g., blockProposalFlusherRegistered) and change
StageBlockProposal to only call SetBlockProposalFlusher(bs.FlushBlockProposal)
when that flag is false, then set it true; ensure the flag is cleared at the end
of the transaction by resetting it inside FlushBlockProposal (or a
backend-callback invoked when the flusher runs) so future transactions can
re-register the flusher.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fvm/environment/facade_env.go`:
- Line 53: The Reset() method on facadeEnvironment must also clear or
reinitialize the BlockProposalCache to avoid carrying stale proposals across
transactions; update the Reset() implementation (which currently resets
ContractUpdater, EventEmitter, and Programs) to either set
facadeEnvironment.BlockProposalCache = NewBlockProposalCache(...) or call its
Clear/Reset method so the cache is empty after Reset(), and make the same change
wherever the same reset logic is duplicated (the other Reset/initialization call
that touches ContractUpdater/EventEmitter/Programs).

In `@fvm/evm/testutils/backend.go`:
- Around line 241-254: The TestBackend currently disables the deferred-write
path by making SetBlockProposalFlusher and FlushBlockProposal no-ops, so tests
calling FlushPendingUpdates can't detect stale-cache or flush-order bugs; modify
TestBackend to store the flusher passed into SetBlockProposalFlusher (e.g., save
it to a new field like blockProposalFlusher) and implement FlushBlockProposal to
invoke that stored function and return its error (or nil if no flusher is set);
keep CachedBlockProposal and CacheBlockProposal as-is so staged proposals are
retained and flushed via the stored flusher when FlushBlockProposal is called.

---

Nitpick comments:
In `@fvm/evm/backends/wrappedEnv.go`:
- Around line 155-158: The FlushBlockProposal method returns the underlying
error directly; update it to wrap the returned error using
handleEnvironmentError for consistency with other methods. Locate
WrappedEnvironment.FlushBlockProposal (currently returning
we.env.FlushBlockProposal()) and change it to capture the error from
we.env.FlushBlockProposal(), and if non-nil pass it into handleEnvironmentError
with the same identifying context used elsewhere (e.g., the method name
"FlushBlockProposal") before returning; follow the exact wrapping pattern used
by GetCurrentBlockHeight/ReadRandom/GenerateUUID to keep behavior consistent.

In `@fvm/evm/handler/blockstore.go`:
- Around line 122-127: StageBlockProposal currently calls
bs.backend.SetBlockProposalFlusher on every invocation, causing redundant
flusher re-registration and closure allocations; add a boolean field on
BlockStore (e.g., blockProposalFlusherRegistered) and change StageBlockProposal
to only call SetBlockProposalFlusher(bs.FlushBlockProposal) when that flag is
false, then set it true; ensure the flag is cleared at the end of the
transaction by resetting it inside FlushBlockProposal (or a backend-callback
invoked when the flusher runs) so future transactions can re-register the
flusher.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ce6e3945-12a0-4abf-83dd-c1a4458a1d7f

📥 Commits

Reviewing files that changed from the base of the PR and between dee4396 and 35ac8b4.

📒 Files selected for processing (14)
  • fvm/environment/block_proposal_cache.go
  • fvm/environment/env.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/block_proposal_cache.go
  • fvm/environment/mock/environment.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/handler/blockstore.go
  • fvm/evm/handler/blockstore_benchmark_test.go
  • fvm/evm/handler/blockstore_test.go
  • fvm/evm/handler/handler.go
  • fvm/evm/testutils/backend.go
  • fvm/evm/types/backend.go
  • fvm/evm/types/handler.go
  • module/state_synchronization/indexer/extended/mock/index_processor.go

Comment thread fvm/environment/facade_env.go Outdated
Comment thread fvm/evm/testutils/backend.go Outdated
Comment thread fvm/evm/handler/blockstore.go Outdated
Comment on lines +51 to +53
if ok && cached != nil {
return cached, nil
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

if ok == false, then it must be a bug, and we could just return an error.

Suggested change
if ok && cached != nil {
return cached, nil
}
if !ok {
return nil, fmt.Errorf("cached block proposal should be *BlockProposal, but got %T", ...)
}
if cached != nil {
return cached, nil
}

Comment thread fvm/evm/handler/blockstore.go Outdated
// facadeEnvironment.FlushPendingUpdates at the end of the Cadence transaction.
func (bs *BlockStore) FlushBlockProposal() error {
cached, ok := bs.backend.CachedBlockProposal().(*types.BlockProposal)
if !ok || cached == nil {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

same here, ok == false should be an error

Comment thread fvm/environment/facade_env.go Outdated
Comment thread fvm/evm/handler/blockstore.go Outdated
// StageBlockProposal updates the in-memory block proposal cache and registers the
// persistence flusher on the backend so it is called at the end of the Cadence transaction.
func (bs *BlockStore) StageBlockProposal(bp *types.BlockProposal) {
bs.backend.SetBlockProposalFlusher(bs.FlushBlockProposal)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This seems to be a redundant call. What about moving it to NewBlockStore? so that it's setup just once. If CacheBlockProposal was never called, FlushBlockProposal will return nil anyway.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

reusableCadenceRuntime is constructed with an empty SwappableEnvironment{} (no inner env), and immediately calls declareStandardLibraryFunctions -> EVMInternalEVMContractValue -> NewBlockStore.
The real environment is only swapped in later when a transaction executes.

NewBlockStore would need to either lazily register the flusher (e.g. on first StageBlockProposal call, which is what it already does), or declareStandardLibraryFunctions would need to be deferred until after the environment swap. The latter would require changes to the Cadence runtime reuse/pooling architecture.

@bluesign
Copy link
Copy Markdown
Contributor

This is very nice, but wouldn't it be better to handle this at FVM level ? I have a feeling state writes can also benefit from this. ( when bundling multiple transactions )

@holyfuchs
Copy link
Copy Markdown
Member Author

This is very nice, but wouldn't it be better to handle this at FVM level ? I have a feeling state writes can also benefit from this. ( when bundling multiple transactions )

My understanding is that this optimization is really about metering rather than execution time / cpu cost. We already maintain a cache for the state, so repeated reads/writes within a block are handled there.
But right now even if that cache can get used extensively in your own transaction you still have to pay as if this wasn't the case.
It should be quite unlikely that a single Cadence transaction writes to the same state multiple times.
But it might be able to be handled there in a cleaner way? @janezpodhostnik

bundling multiple transactions

I assume you mean multiple transaction in the same block.

It wouldn’t really make sense for transactions to get cheaper just because a later transaction touches the same storage slot.

@onflow onflow deleted a comment from coderabbitai Bot Mar 31, 2026
@bluesign
Copy link
Copy Markdown
Contributor

thanks @holyfuchs this makes sense.

It wouldn’t really make sense for transactions to get cheaper just because a later transaction touches the same storage slot.

Technically I agree, but practically, as we will have some overhead there, not 100% sure. I was thinking more like intermittent read/writes we charge here ( for example: I send some FT to multiple addresses, like airdrop ) but not sure about internals there, @fxamacker may now more, maybe atree already handles those in map )

@janezpodhostnik
Copy link
Copy Markdown
Contributor

@bluesign the cache for state reads/writes on the FVM is already cached, and the cache is fairly close to where it is needed (in fvm transaction state). We could probably get some improvement if it would be cached even higher, but not sure how much that would improve stuff and it would definitely add complexity. It also wouldn't really matter for computation since we don't meter atree reads/writes during EVM execution since we rely on EVM gas metering to do the job there.

I would like to look into this for additional performance improvements on the EVM side, but lets do that separately. We actually haven't looked that deeply yet when it comes to optimizing the EVM calls, so there is definitely work to be done there.

The block proposal cache change this PR is addressing is "relatively" straightforward and gives a lot of reduction to computation used.

(even if we added a cache for all EVM registers later I don't think this effort is wasted/overshadowed, since block proposal needs special handling anyway)

Originally we thought that if people needed to run multiple EVM transactions in a cadence transaction, they would just use the batch operations, but it looks like in some cases that is not convenient or possible (when you want the transaction to do some EVM stuff then some cadence stuff and some EVM stuff again).

@m-Peter
Copy link
Copy Markdown
Collaborator

m-Peter commented Mar 31, 2026

Originally we thought that if people needed to run multiple EVM transactions in a cadence transaction, they would just use the batch operations, but it looks like in some cases that is not convenient or possible (when you want the transaction to do some EVM stuff then some cadence stuff and some EVM stuff again).

@janezpodhostnik To add on top of that, in some cases the developers may not even have a choice here. By that I mean that when you interact with various Cadence contracts, to compose some logic in your own transaction, or even your own smart contract, the EVM transactions may come from contracts outside of the developers reach. So you can end up with 10-15 EVM transactions, that are impossible to glue together in a EVM.batchRun. That's why the caching of EVM block proposal, will definitely improve quite a lot of cases of EVM.run, where a single Cadence transaction, composes many EVM calls/transactions.

@bluesign
Copy link
Copy Markdown
Contributor

since we don't meter atree reads/writes during EVM execution since we rely on EVM gas metering to do the job there.

was there a change on this? I am not so up to date with flow-go code base anymore, but I think we were charging those to GW account.

The block proposal cache change this PR is addressing is "relatively" straightforward and gives a lot of reduction to computation used.

yeah agreed here totally

(even if we added a cache for all EVM registers later I don't think this effort is wasted/overshadowed, since block proposal needs special handling anyway)

I was thinking a bit this EVM register cache will cover this.

I think this PR is totally fine. As you said more improvements can be another PR, this block proposal is alone a major gain.

Copy link
Copy Markdown
Contributor

@janezpodhostnik janezpodhostnik left a comment

Choose a reason for hiding this comment

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

Overall I think this is on the right path. I think the FlushBlockProposal method is used in the correct place.

I think the main design issue to address before going into a more detailed review is the reason why the flusher registration is needed in the first place:

The BlockStore is on the EVM store, and has the same life-cycle as the ReusableCadenceRuntime which means it cannot keep its own state, so we need to keep some state for it in the BlockProposalCache. But looking at the BlockStore there is no reason its on the EVM side. The block store would have access to everything it needs on the fvm side as part of the fvm.Environment. Moving the BlockStore to the environment allows the block store to do its own caching and flushing (and reseting in case of erors) while only serving a couple of methods to the EVM side (to get/create the block proposal)

So I propose trying to move the BlockStore to fvm Environment (and call it EVMBlockStore). then handle caching/flushing/reseting on the EVMBlockStore itself.

If there are any dependencies issue we can pull some interfaces out to a separate package,

@janezpodhostnik
Copy link
Copy Markdown
Contributor

was there a change on this? I am not so up to date with flow-go code base anymore, but I think we were charging those to GW account.

yeah. This was a fairly recent change that came with the fees re-calibration. If you look at

func (h *ContractHandler) run(rlpEncodedTx []byte) (*types.Result, error) {
there is RunWithMeteringDisabled for runiung the EVM code.

@bluesign
Copy link
Copy Markdown
Contributor

@janezpodhostnik oh this is cool, can't we just use blockProposal the same logic, something like:

	h.backend.RunWithMeteringDisabled(
		func() {
	bp, err = h.getBlockProposal() // same for updateBlockProposal
})
if err != nil {
		return nil, err
}

technically EVM is metering transaction overhead.

@janezpodhostnik
Copy link
Copy Markdown
Contributor

Well that part isn't really accounted for with EVM gas (it costs 0 EVM gas to make a block proposal) so if it also costs 0 FVM computation, we risk it becoming an exploit.

@holyfuchs
Copy link
Copy Markdown
Member Author

flow_master transactions profile 53d2d8169a77851b1a729e19cd2e39642e1ed20519cd14c07ac4eff307f24b71 --network mainnet
Computation: 4912

flow_this_pr transactions profile 53d2d8169a77851b1a729e19cd2e39642e1ed20519cd14c07ac4eff307f24b71 --network mainnet
Computation: 4554

So this would further reduce computation cost by around 7.3%.

@bluesign
Copy link
Copy Markdown
Contributor

This is pretty good result for this one, it is pretty cadence heavy. in normal EVM heavy transactions ( like https://www.flowscan.io/tx/2f52c22648e252b203134fa94729699d9f74f99aa723882f5dad37b0bf99a5f9 ) it would be like 40%-50% probably.

@holyfuchs
Copy link
Copy Markdown
Member Author

Scenario 1: Real transaction

Source: Flowscan Tx 53d2d816...

Version Gas Cost % Reduction (vs Deployed)
Deployed 6,862
Master 4,912 -28.4%
This PR 4,554 -33.6%
  • Improvement vs Master: This PR reduces gas by 7.3% compared to the current master branch.

Scenario 2: Real transaction

Source: Flowscan Tx 2f52c226...

Version Gas Cost % Reduction (vs Deployed)
Deployed 434
Master 294 -32.2%
This PR 255 -41.2%
  • Improvement vs Master: This PR reduces gas by 13.3% compared to the current master branch.

Scenario 3: artifical a lot of EVM calls

Source: Testnet Flowscan Tx c5a7c959...

Version Gas Cost % Reduction (vs Deployed)
Deployed 798
Master 697 -12.7%
This PR 459 -42.5%
  • Improvement vs Master: This PR reduces gas by 34.1% compared to the current master branch.

@holyfuchs holyfuchs force-pushed the holyfuchs/6958-evm-optimize-block-formation branch from 35ac8b4 to 7675a17 Compare April 1, 2026 22:38
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
fvm/environment/evm_block_store_benchmark_test.go (1)

21-28: Misleading error check placement.

The require.NoError(b, err) on line 27 checks the error from bs.BlockProposal() on line 22, but it appears after StageBlockProposal which doesn't return an error. This is technically correct but confusing. Consider moving the check immediately after the BlockProposal call for clarity.

Proposed refactor for clarity
 			for i := 0; i < txCounts; i++ {
 				bp, err := bs.BlockProposal()
 				require.NoError(b, err)
 				res := testutils.RandomResultFixture(b)
 				bp.AppendTransaction(res)
 				bs.StageBlockProposal(bp)
-				require.NoError(b, err)
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@fvm/environment/evm_block_store_benchmark_test.go` around lines 21 - 28, The
require.NoError call is misplaced: it asserts the error returned by
bs.BlockProposal() but sits after bs.StageBlockProposal(), which is confusing;
move the require.NoError(b, err) to immediately after the bp, err :=
bs.BlockProposal() call (before using bp and before bp.AppendTransaction), and
remove the trailing duplicate require.NoError(b, err) after
bs.StageBlockProposal(); this keeps error checking adjacent to the failing call
(bs.BlockProposal) and preserves use of bp.AppendTransaction and
bs.StageBlockProposal as-is.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fvm/environment/evm_block_store_test.go`:
- Around line 55-63: The call to bs.FlushBlockProposal() in the test ignores its
returned error; update the test to capture that error and assert it's nil (e.g.,
using require.NoError(t, err)) immediately after calling bs.FlushBlockProposal()
so failures are detected; keep the rest of the sequence (bp.TotalSupply set,
bs.StageBlockProposal(bp), then err := bs.FlushBlockProposal();
require.NoError(t, err), then call bs.LatestBlock()).

In `@fvm/environment/evm_block_store.go`:
- Around line 251-254: StageBlockProposal currently ignores the error returned
by updateBlockProposal, so change BlockStore.StageBlockProposal to return an
error and propagate the result of bs.updateBlockProposal(bs.cached) (i.e.,
return the error instead of discarding it); update the EVMBlockStore interface
signature to reflect StageBlockProposal() error and modify
NoEVMBlockStore.StageBlockProposal to implement the new signature (returning nil
or the propagated error as appropriate) so callers can detect storage write
failures.

In `@fvm/evm/handler/blockstore_benchmark_test.go`:
- Around line 26-27: The require.NoError(b, err) after bs.StageBlockProposal(bp)
is asserting a stale error from the earlier BlockProposal call; update the code
to capture StageBlockProposal's return error and assert that instead (e.g., set
err = bs.StageBlockProposal(bp) and then call require.NoError(b, err)), or
remove the stale assertion if StageBlockProposal does not return an error;
ensure the assertion references the error value returned by StageBlockProposal
rather than the previous BlockProposal error.

---

Nitpick comments:
In `@fvm/environment/evm_block_store_benchmark_test.go`:
- Around line 21-28: The require.NoError call is misplaced: it asserts the error
returned by bs.BlockProposal() but sits after bs.StageBlockProposal(), which is
confusing; move the require.NoError(b, err) to immediately after the bp, err :=
bs.BlockProposal() call (before using bp and before bp.AppendTransaction), and
remove the trailing duplicate require.NoError(b, err) after
bs.StageBlockProposal(); this keeps error checking adjacent to the failing call
(bs.BlockProposal) and preserves use of bp.AppendTransaction and
bs.StageBlockProposal as-is.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0098b387-ac76-422c-baca-bc8b4d0030fe

📥 Commits

Reviewing files that changed from the base of the PR and between 35ac8b4 and 7675a17.

📒 Files selected for processing (30)
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/environment/env.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/environment/evm_block_store.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/evm_block_store_test.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/environment.go
  • fvm/environment/mock/evm_block_store.go
  • fvm/evm/backends/backend.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/evm_test.go
  • fvm/evm/handler/blockstore_benchmark_test.go
  • fvm/evm/handler/blockstore_test.go
  • fvm/evm/handler/handler.go
  • fvm/evm/handler/handler_test.go
  • fvm/evm/handler/precompiles.go
  • fvm/evm/offchain/blocks/blocks.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/evm/offchain/storage/ephemeral.go
  • fvm/evm/offchain/storage/readonly.go
  • fvm/evm/offchain/sync/replay.go
  • fvm/evm/testutils/backend.go
  • fvm/evm/testutils/handler.go
  • fvm/evm/testutils/offchain.go
  • fvm/evm/types/handler.go
  • fvm/fvm_test.go
  • fvm/runtime/cadence_function_declarations.go
  • module/state_synchronization/indexer/extended/mock/index_processor.go
💤 Files with no reviewable changes (2)
  • fvm/evm/types/handler.go
  • fvm/runtime/cadence_function_declarations.go
✅ Files skipped from review due to trivial changes (1)
  • fvm/environment/mock/evm_block_store.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • fvm/evm/handler/blockstore_test.go
  • fvm/evm/backends/wrappedEnv.go

Comment thread fvm/environment/evm_block_store_test.go
Comment thread fvm/evm/handler/blockstore_benchmark_test.go Outdated
@holyfuchs holyfuchs force-pushed the holyfuchs/6958-evm-optimize-block-formation branch from 7675a17 to 511c8ee Compare April 1, 2026 23:02
@onflow onflow deleted a comment from coderabbitai Bot Apr 1, 2026
@holyfuchs holyfuchs force-pushed the holyfuchs/6958-evm-optimize-block-formation branch from 511c8ee to baa4e25 Compare April 1, 2026 23:06
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
fvm/environment/evm_block_store.go (1)

190-198: ⚠️ Potential issue | 🟠 Major

CommitBlockProposal is still doing the final proposal write on the hot path.

fvm/environment/facade_env.go:350-370 already calls FlushBlockProposal() during transaction flush. Persisting newBP here means every successful commit pays a second SetValue for LatestBlockProposal, which gives back part of the metering win this change is trying to capture. Keep newBP in bs.cached here and let the final flush own persistence.

♻️ Proposed fix
 	newBP, err := bs.constructBlockProposal()
 	if err != nil {
 		return err
 	}
-	err = bs.updateBlockProposal(newBP)
-	if err != nil {
-		return err
-	}
 	bs.cached = newBP
 	return nil
🤖 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 190 - 198,
CommitBlockProposal currently persists the proposal on the hot path by calling
updateBlockProposal(newBP) after constructBlockProposal; remove that persistence
so CommitBlockProposal only assigns bs.cached = newBP and returns, letting
FlushBlockProposal perform the final write later. Locate the
constructBlockProposal / updateBlockProposal usage in CommitBlockProposal,
delete the updateBlockProposal call (and its error handling), and ensure
bs.cached is set to newBP and CommitBlockProposal returns nil; keep
FlushBlockProposal as the sole writer of LatestBlockProposal.
🧹 Nitpick comments (1)
fvm/environment/evm_block_store.go (1)

224-240: Add caching for BlockHashList to avoid repeated storage reads per transaction.

BlockStore caches BlockProposal but creates a new BlockHashList on every call to getBlockHashList() (lines 224-225), which always invokes NewBlockHashList()loadMetaData()backend.GetValue(). Both BlockHash() (line 216) and CommitBlockProposal() (line 180) call getBlockHashList(), and multiple BlockHash() calls during a single transaction will re-read the same metadata from storage repeatedly.

Add a cached *BlockHashList field to BlockStore (similar to the cached *types.BlockProposal pattern) and initialize it once at transaction start, or refactor getBlockHashList() to cache the instance within BlockStore and invalidate only on CommitBlockProposal().

🤖 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 224 - 240, BlockStore
currently creates a new BlockHashList on every getBlockHashList() call causing
repeated storage reads; add a cached field (e.g., cachedBlockHashList
*BlockHashList) to the BlockStore struct and change getBlockHashList() to return
the cached instance if present, otherwise create, cache, and return it; ensure
CommitBlockProposal() (and any other mutation paths) clears/invalidate
cachedBlockHashList (similar to the existing cached *types.BlockProposal
pattern) so the cache stays consistent; update any constructors/tests as needed
to handle the new field.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@fvm/environment/evm_block_store.go`:
- Around line 190-198: CommitBlockProposal currently persists the proposal on
the hot path by calling updateBlockProposal(newBP) after constructBlockProposal;
remove that persistence so CommitBlockProposal only assigns bs.cached = newBP
and returns, letting FlushBlockProposal perform the final write later. Locate
the constructBlockProposal / updateBlockProposal usage in CommitBlockProposal,
delete the updateBlockProposal call (and its error handling), and ensure
bs.cached is set to newBP and CommitBlockProposal returns nil; keep
FlushBlockProposal as the sole writer of LatestBlockProposal.

---

Nitpick comments:
In `@fvm/environment/evm_block_store.go`:
- Around line 224-240: BlockStore currently creates a new BlockHashList on every
getBlockHashList() call causing repeated storage reads; add a cached field
(e.g., cachedBlockHashList *BlockHashList) to the BlockStore struct and change
getBlockHashList() to return the cached instance if present, otherwise create,
cache, and return it; ensure CommitBlockProposal() (and any other mutation
paths) clears/invalidate cachedBlockHashList (similar to the existing cached
*types.BlockProposal pattern) so the cache stays consistent; update any
constructors/tests as needed to handle the new field.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5525c069-bc28-46d8-879b-dcef1cd588d7

📥 Commits

Reviewing files that changed from the base of the PR and between 7675a17 and 511c8ee.

📒 Files selected for processing (29)
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/environment/env.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/environment/evm_block_store.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/evm_block_store_test.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/environment.go
  • fvm/environment/mock/evm_block_store.go
  • fvm/evm/backends/backend.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/evm_test.go
  • fvm/evm/handler/blockstore_benchmark_test.go
  • fvm/evm/handler/blockstore_test.go
  • fvm/evm/handler/handler.go
  • fvm/evm/handler/handler_test.go
  • fvm/evm/handler/precompiles.go
  • fvm/evm/offchain/blocks/blocks.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/evm/offchain/storage/ephemeral.go
  • fvm/evm/offchain/storage/readonly.go
  • fvm/evm/offchain/sync/replay.go
  • fvm/evm/testutils/backend.go
  • fvm/evm/testutils/handler.go
  • fvm/evm/testutils/offchain.go
  • fvm/evm/types/handler.go
  • fvm/fvm_test.go
  • fvm/runtime/cadence_function_declarations.go
💤 Files with no reviewable changes (2)
  • fvm/runtime/cadence_function_declarations.go
  • fvm/evm/types/handler.go
✅ Files skipped from review due to trivial changes (5)
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/evm/offchain/storage/readonly.go
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/evm/handler/blockstore_benchmark_test.go
  • fvm/evm/offchain/blocks/provider.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • fvm/environment/env.go
  • fvm/evm/offchain/sync/replay.go
  • fvm/evm/testutils/handler.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/evm/offchain/blocks/blocks.go
  • fvm/evm/handler/handler_test.go
  • fvm/environment/evm_block_store_test.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/evm_block_store.go
  • fvm/evm/handler/precompiles.go

@holyfuchs holyfuchs force-pushed the holyfuchs/6958-evm-optimize-block-formation branch from baa4e25 to 5b7f841 Compare April 1, 2026 23:22
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
fvm/environment/evm_block_store_test.go (1)

31-60: Add a read-only / reset regression here.

This only exercises the happy-path StageBlockProposal()FlushBlockProposal() flow. The new contract also needs a case where BlockProposal() is read without staging and FlushBlockProposal() is a no-op, plus a case where ResetBlockProposal() drops staged changes; otherwise the dry-run and failed-transaction paths can regress unnoticed.

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

In `@fvm/environment/evm_block_store_test.go` around lines 31 - 60, Add tests
covering read-only and reset regression: after initial BlockProposal() call,
call BlockProposal() again without StageBlockProposal and then call
FlushBlockProposal() and assert no changes (FlushBlockProposal is a no-op and
returned proposal equals original); also test ResetBlockProposal by
StageBlockProposal() with modified fields (e.g., TotalGasUsed or TotalSupply),
call ResetBlockProposal(), then call BlockProposal() and assert the returned
proposal does not include staged changes (staged modifications were dropped).
Reference BlockProposal, StageBlockProposal, FlushBlockProposal, and
ResetBlockProposal when adding these assertions.
fvm/environment/facade_env.go (1)

208-214: Construct EVMBlockStore after parse guards are installed.

NewScriptEnv() does this in the safe order, but NewTransactionEnvironment() builds the block store first. Since NewBlockStore(...) captures env.ValueStore, env.BlockInfo, and env.RandomGenerator, the transaction path currently holds the unwrapped implementations and can bypass the parse-restricted guard layer.

Suggested change
-	env.EVMBlockStore = NewBlockStore(
-		params.Chain.ChainID(),
-		env.ValueStore,
-		env.BlockInfo,
-		env.RandomGenerator,
-		evm.StorageAccountAddress(params.Chain.ChainID()),
-	)
-
 	env.addParseRestrictedChecks()
+
+	env.EVMBlockStore = NewBlockStore(
+		params.Chain.ChainID(),
+		env.ValueStore,
+		env.BlockInfo,
+		env.RandomGenerator,
+		evm.StorageAccountAddress(params.Chain.ChainID()),
+	)

Also applies to: 285-291

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

In `@fvm/environment/facade_env.go` around lines 208 - 214, The EVMBlockStore is
constructed too early in NewTransactionEnvironment (via the NewBlockStore call
that assigns env.EVMBlockStore), capturing raw env.ValueStore, env.BlockInfo,
and env.RandomGenerator before parse guards are wrapped; move the NewBlockStore
construction to after the parse-guard installation (mirror the safe ordering
used in NewScriptEnv) so env.EVMBlockStore is created with the wrapped/guarded
ValueStore, BlockInfo and RandomGenerator instances; update both occurrences
(the one at the NewTransactionEnvironment block and the similar block around
lines 285-291) to defer NewBlockStore until after the guard setup.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@fvm/environment/evm_block_store.go`:
- Around line 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).
- Around line 75-98: BlockProposal currently sets bs.cached on every read which
makes reads stateful and causes subsequent FlushBlockProposal to write even when
nothing was staged; change the logic so BlockProposal only returns the existing
persisted proposal (using constructBlockProposal when absent) without mutating
bs.cached, introduce or use a separate staged field (e.g., bs.stagedProposal or
mark via StageBlockProposal) and make StageBlockProposal responsible for setting
that staged state, have FlushBlockProposal/CommitBlockProposal only persist when
the staged field is non-nil/dirty, and ensure CommitBlockProposal clears the
staged dirty state after a successful persist; update references in
BlockProposal, FlushBlockProposal, StageBlockProposal, CommitBlockProposal and
constructBlockProposal accordingly.

---

Nitpick comments:
In `@fvm/environment/evm_block_store_test.go`:
- Around line 31-60: Add tests covering read-only and reset regression: after
initial BlockProposal() call, call BlockProposal() again without
StageBlockProposal and then call FlushBlockProposal() and assert no changes
(FlushBlockProposal is a no-op and returned proposal equals original); also test
ResetBlockProposal by StageBlockProposal() with modified fields (e.g.,
TotalGasUsed or TotalSupply), call ResetBlockProposal(), then call
BlockProposal() and assert the returned proposal does not include staged changes
(staged modifications were dropped). Reference BlockProposal,
StageBlockProposal, FlushBlockProposal, and ResetBlockProposal when adding these
assertions.

In `@fvm/environment/facade_env.go`:
- Around line 208-214: The EVMBlockStore is constructed too early in
NewTransactionEnvironment (via the NewBlockStore call that assigns
env.EVMBlockStore), capturing raw env.ValueStore, env.BlockInfo, and
env.RandomGenerator before parse guards are wrapped; move the NewBlockStore
construction to after the parse-guard installation (mirror the safe ordering
used in NewScriptEnv) so env.EVMBlockStore is created with the wrapped/guarded
ValueStore, BlockInfo and RandomGenerator instances; update both occurrences
(the one at the NewTransactionEnvironment block and the similar block around
lines 285-291) to defer NewBlockStore until after the guard setup.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aaa80248-e7e9-49c5-84cd-c74889a356c9

📥 Commits

Reviewing files that changed from the base of the PR and between 511c8ee and baa4e25.

📒 Files selected for processing (27)
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/environment/env.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/environment/evm_block_store.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/evm_block_store_test.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/environment.go
  • fvm/environment/mock/evm_block_store.go
  • fvm/evm/backends/backend.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/evm_test.go
  • fvm/evm/handler/handler.go
  • fvm/evm/handler/handler_test.go
  • fvm/evm/handler/precompiles.go
  • fvm/evm/offchain/blocks/blocks.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/evm/offchain/storage/ephemeral.go
  • fvm/evm/offchain/storage/readonly.go
  • fvm/evm/offchain/sync/replay.go
  • fvm/evm/testutils/backend.go
  • fvm/evm/testutils/handler.go
  • fvm/evm/testutils/offchain.go
  • fvm/evm/types/handler.go
  • fvm/fvm_test.go
  • fvm/runtime/cadence_function_declarations.go
💤 Files with no reviewable changes (2)
  • fvm/runtime/cadence_function_declarations.go
  • fvm/evm/types/handler.go
✅ Files skipped from review due to trivial changes (3)
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/environment/mock/evm_block_store.go
🚧 Files skipped from review as they are similar to previous changes (11)
  • fvm/environment/env.go
  • fvm/evm/offchain/storage/readonly.go
  • fvm/evm/testutils/offchain.go
  • fvm/evm/backends/backend.go
  • fvm/evm/handler/handler_test.go
  • fvm/fvm_test.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/evm/handler/precompiles.go
  • fvm/evm/testutils/handler.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/evm/offchain/blocks/blocks.go

Comment on lines 46 to 53
type BlockStore struct {
chainID flow.ChainID
backend types.Backend
storage ValueStore
blockInfo BlockInfo
randGen RandomGenerator
rootAddress flow.Address
cached *types.BlockProposal
}
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).

Comment thread fvm/environment/evm_block_store.go
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
fvm/environment/evm_block_store.go (2)

46-53: ⚠️ Potential issue | 🟠 Major

Cache BlockHashList on the store too.

Lines 46-53 only cache the proposal, but Lines 224-240 still rebuild BlockHashList from storage on every BlockHash() and CommitBlockProposal() call. Repeated BLOCKHASH access inside one Cadence transaction therefore stays on the metered storage path, so the block-hash half of this optimization is still missing.

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
 }

If the same BlockStore instance can survive failed transactions, clear this cache on reset too. As per coding guidelines, "Use efficient data structures and algorithms as performance is critical in this blockchain protocol implementation."

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, The BlockStore
caches only cached *types.BlockProposal* but not the reconstructed
BlockHashList, so BlockHash() and CommitBlockProposal() still rebuild the list
from storage; extend BlockStore (the struct defined in BlockStore) with a cached
BlockHashList field (e.g., blockHashListCache) and update the code paths in
BlockHash() and CommitBlockProposal() to use and update that in-memory cache
instead of reconstructing from storage; also ensure the existing reset/Reset
method clears this new cache (same place that clears cached) so failed
transactions don't leak stale data.

75-97: ⚠️ Potential issue | 🔴 Critical

Track dirty proposal state separately from the read cache.

Line 76 makes BlockProposal() stateful, Line 198 leaves the freshly committed proposal in that same cache, and Lines 251-259 flush any non-nil cache. DryRun() still calls BlockProposal() in fvm/evm/handler/handler.go:547-554, while fvm/environment/facade_env.go:351-358 flushes at transaction end. A non-mutating EVM read can therefore persist a new proposal, and CommitBlockProposal() will be written again on the final flush.

Minimal fix
 type BlockStore struct {
 	chainID     flow.ChainID
 	storage     ValueStore
 	blockInfo   BlockInfo
 	randGen     RandomGenerator
 	rootAddress flow.Address
 	cached      *types.BlockProposal
+	dirty       bool
 }
@@
 	if len(data) != 0 {
 		bp, err := types.NewBlockProposalFromBytes(data)
 		if err != nil {
 			return nil, err
 		}
 		bs.cached = bp
+		bs.dirty = false
 		return bp, nil
 	}
@@
 	bs.cached = bp
+	bs.dirty = false
 	return bp, nil
 }
@@
 	err = bs.updateBlockProposal(newBP)
 	if err != nil {
 		return err
 	}
 	bs.cached = newBP
+	bs.dirty = false
 	return nil
 }
@@
 func (bs *BlockStore) ResetBlockProposal() {
 	bs.cached = nil
+	bs.dirty = false
 }
 
 func (bs *BlockStore) StageBlockProposal(bp *types.BlockProposal) {
 	bs.cached = bp
+	bs.dirty = true
 }
 
 func (bs *BlockStore) FlushBlockProposal() error {
-	if bs.cached == nil {
+	if bs.cached == nil || !bs.dirty {
 		return nil
 	}
 	err := bs.updateBlockProposal(bs.cached)
 	if err != nil {
 		return err
 	}
+	bs.dirty = false
 	return nil
 }

Also applies to: 194-199, 243-259

🤖 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 75 - 97, BlockProposal()
currently reuses bs.cached for both a read cache and a mutated "dirty" proposal
which lets non-mutating reads persist changes; introduce a separate dirty flag
(e.g., bs.proposalDirty bool) on BlockStore, ensure BlockProposal() only
populates bs.cached without setting bs.proposalDirty, have CommitBlockProposal()
set bs.proposalDirty = true when it mutates the proposal, and change the
flush/commit logic that currently checks bs.cached to only write to storage when
bs.proposalDirty is true and then clear bs.proposalDirty (and keep bs.cached
available for reads). Ensure all references to cached-flushing (the flush method
and CommitBlockProposal/constructBlockProposal paths) follow this new dirty flag
behavior.
🧹 Nitpick comments (1)
fvm/environment/facade_env.go (1)

208-214: Extract the NewBlockStore(...) wiring into one helper.

These two constructors already drifted in ordering around addParseRestrictedChecks(). Pulling the shared block-store setup behind one helper will keep the script and transaction environments from diverging further as this lifecycle grows.

Also applies to: 285-291

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

In `@fvm/environment/facade_env.go` around lines 208 - 214, The NewBlockStore
wiring for env.EVMBlockStore should be extracted into a single helper to avoid
drift between script and transaction environments; create a function (e.g.,
initBlockStore or setupBlockStore) that accepts the shared inputs
(params.Chain.ChainID(), env.ValueStore, env.BlockInfo, env.RandomGenerator,
evm.StorageAccountAddress(params.Chain.ChainID())) and returns the constructed
BlockStore, then replace the two inline NewBlockStore(...) occurrences (the one
assigning env.EVMBlockStore and the other at lines referenced in the comment)
with calls to this helper so both environments use the same construction order
and logic, and ensure any surrounding calls like addParseRestrictedChecks()
remain in the same place relative to the helper call.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@fvm/environment/evm_block_store.go`:
- Around line 46-53: The BlockStore caches only cached *types.BlockProposal* but
not the reconstructed BlockHashList, so BlockHash() and CommitBlockProposal()
still rebuild the list from storage; extend BlockStore (the struct defined in
BlockStore) with a cached BlockHashList field (e.g., blockHashListCache) and
update the code paths in BlockHash() and CommitBlockProposal() to use and update
that in-memory cache instead of reconstructing from storage; also ensure the
existing reset/Reset method clears this new cache (same place that clears
cached) so failed transactions don't leak stale data.
- Around line 75-97: BlockProposal() currently reuses bs.cached for both a read
cache and a mutated "dirty" proposal which lets non-mutating reads persist
changes; introduce a separate dirty flag (e.g., bs.proposalDirty bool) on
BlockStore, ensure BlockProposal() only populates bs.cached without setting
bs.proposalDirty, have CommitBlockProposal() set bs.proposalDirty = true when it
mutates the proposal, and change the flush/commit logic that currently checks
bs.cached to only write to storage when bs.proposalDirty is true and then clear
bs.proposalDirty (and keep bs.cached available for reads). Ensure all references
to cached-flushing (the flush method and
CommitBlockProposal/constructBlockProposal paths) follow this new dirty flag
behavior.

---

Nitpick comments:
In `@fvm/environment/facade_env.go`:
- Around line 208-214: The NewBlockStore wiring for env.EVMBlockStore should be
extracted into a single helper to avoid drift between script and transaction
environments; create a function (e.g., initBlockStore or setupBlockStore) that
accepts the shared inputs (params.Chain.ChainID(), env.ValueStore,
env.BlockInfo, env.RandomGenerator,
evm.StorageAccountAddress(params.Chain.ChainID())) and returns the constructed
BlockStore, then replace the two inline NewBlockStore(...) occurrences (the one
assigning env.EVMBlockStore and the other at lines referenced in the comment)
with calls to this helper so both environments use the same construction order
and logic, and ensure any surrounding calls like addParseRestrictedChecks()
remain in the same place relative to the helper call.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2eabf38b-9ad3-4f73-ba7b-7a461a2cbf28

📥 Commits

Reviewing files that changed from the base of the PR and between baa4e25 and 5b7f841.

📒 Files selected for processing (28)
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/environment/env.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/environment/evm_block_store.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/evm_block_store_test.go
  • fvm/environment/facade_env.go
  • fvm/environment/mock/environment.go
  • fvm/environment/mock/evm_block_store.go
  • fvm/environment/mock/reusable_cadence_runtime_interface.go
  • fvm/evm/backends/backend.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/evm_test.go
  • fvm/evm/handler/handler.go
  • fvm/evm/handler/handler_test.go
  • fvm/evm/handler/precompiles.go
  • fvm/evm/offchain/blocks/blocks.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/evm/offchain/storage/ephemeral.go
  • fvm/evm/offchain/storage/readonly.go
  • fvm/evm/offchain/sync/replay.go
  • fvm/evm/testutils/backend.go
  • fvm/evm/testutils/handler.go
  • fvm/evm/testutils/offchain.go
  • fvm/evm/types/handler.go
  • fvm/fvm_test.go
  • fvm/runtime/cadence_function_declarations.go
💤 Files with no reviewable changes (3)
  • fvm/evm/types/handler.go
  • fvm/environment/mock/reusable_cadence_runtime_interface.go
  • fvm/runtime/cadence_function_declarations.go
✅ Files skipped from review due to trivial changes (6)
  • fvm/evm/offchain/storage/readonly.go
  • fvm/environment/evm_block_hash_list_test.go
  • fvm/evm/offchain/blocks/provider.go
  • fvm/evm/backends/wrappedEnv.go
  • fvm/evm/testutils/backend.go
  • fvm/environment/mock/evm_block_store.go
🚧 Files skipped from review as they are similar to previous changes (9)
  • fvm/evm/testutils/offchain.go
  • fvm/environment/env.go
  • fvm/evm/backends/backend.go
  • cmd/util/cmd/checkpoint-collect-stats/cmd.go
  • fvm/environment/evm_block_store_benchmark_test.go
  • fvm/environment/evm_block_hash_list.go
  • fvm/environment/evm_block_store_test.go
  • fvm/evm/evm_test.go
  • fvm/evm/offchain/storage/ephemeral.go

@holyfuchs
Copy link
Copy Markdown
Member Author

holyfuchs commented Apr 2, 2026

♻️ Duplicate comments (2)

fvm/environment/evm_block_store.go (2)> 46-53: ⚠️ Potential issue | 🟠 Major

Cache BlockHashList on the store too.
Lines 46-53 only cache the proposal, but Lines 224-240 still rebuild BlockHashList from storage on every BlockHash() and CommitBlockProposal() call. Repeated BLOCKHASH access inside one Cadence transaction therefore stays on the metered storage path, so the block-hash half of this optimization is still missing.

I doubt it's gonna be called often and the list is much larger don't think it makes sense to cache it.

75-97: ⚠️ Potential issue | 🔴 Critical
Track dirty proposal state separately from the read cache.
Line 76 makes BlockProposal() stateful, Line 198 leaves the freshly committed proposal in that same cache, and Lines 251-259 flush any non-nil cache. DryRun() still calls BlockProposal() in fvm/evm/handler/handler.go:547-554, while fvm/environment/facade_env.go:351-358 flushes at transaction end. A non-mutating EVM read can therefore persist a new proposal, and CommitBlockProposal() will be written again on the final flush.

I don't think its an issue but will follow this up.
Update: Yeah tests including this case are passing.

@janezpodhostnik
Copy link
Copy Markdown
Contributor

Hey @holyfuchs I pushed a commit to reduce the interface size a little bit. Can you check it.

Comment thread fvm/environment/evm_block_store.go Outdated
return err
}

bs.cached = newBP
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we can clear the cache after the block proposal is committed, because it won't be used any more.

Suggested change
bs.cached = newBP
bs.cached = nil

The CommitBlockProposal is used at the system tx, which is the last tx in a flow block, so there is no more tx to read this cache.

- Flow Block 100
|--cadence tx 1 (succeed)
  |-- EVM Tx A 
    |-- StageBlockProposal() -> storage unchanged, create cache
  |-- EVM Tx B 
    |-- FlushBlockProposal() -> write cache to storage (LatestBlockProposal), cache = nil
|--cadence tx 2 (failed)
  |-- EVM Tx C
    |-- StageBlockProposal() -> storage unchanged, create cache
  |-- EVM Tx D (fail and revert)
    |-- ResetBlockProposal() -> storage unchanged, cache = nil
|--system chunk tx (last tx)
  |--heartbeat()
    |--CommitBlockProposal() -> write cache to storage (LatestBlock, LatestBlockProposal), cache = nil

There are two keys in storage:

  • LatestBlock
  • LatestBlockProposal

The relationship is that LatestBlockProposal.Parent == LatestBlock, but when CommitBlockProposal updates the LatestBlock, this equation no longer holds, that's why we need to also call constructBlockProposal() to create a new empty block proposal in order to satisfy the equation (LatestBlockProposal.Parent == LatestBlock). However, we don't have to set cache = nil, because the cache won't be used anymore.

IMO, the CommitBlockProposal could just remove the LatestBlockProposal key after updating LatestBlock, because the bs.BlockProposal() method already handles the case where LatestBlockProposal is missing, we could just reuse the same code path. And then it would look more consistent when we remove both the LatestBlockProposal key and set cache = nil in CommitBlockProposal().

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

⏺ 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
              ├── remove LatestBlockProposal (new proposal will be constructed lazily in next flow block)
              └── cache = nil

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Comment on lines +225 to +234
// Remove LatestBlockProposal key - the new proposal will be constructed lazily
// on the next BlockProposal() call by reading LatestBlock for parent hash and height.
err = bs.storage.SetValue(
bs.rootAddress[:],
[]byte(BlockStoreLatestBlockProposalKey),
nil, // setting to nil removes the key
)
if err != nil {
return err
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This file was copied from evm/handler/blockstore.go, except here we are removing the last block proposal key instead of creating new one (the new one will be created lazily, see details in this PR)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Flow EVM] Optimize block formation

7 participants