Skip to content

fix: compute Gloas envelope stateRoot from postState#9255

Closed
lodekeeper wants to merge 2 commits intoChainSafe:unstablefrom
lodekeeper:fix/gloas-envelope-state-root
Closed

fix: compute Gloas envelope stateRoot from postState#9255
lodekeeper wants to merge 2 commits intoChainSafe:unstablefrom
lodekeeper:fix/gloas-envelope-state-root

Conversation

@lodekeeper
Copy link
Copy Markdown
Contributor

PR draft — Gloas envelope stateRoot fix

Proposed title

fix: compute Gloas envelope stateRoot from postState

Proposed body

Summary

Fix Gloas self-build execution payload envelopes to use the correct envelope-specific stateRoot.

Previously getExecutionPayloadEnvelope() could return an invalid stateRoot for self-build Gloas envelopes:

  • first as ZERO_HASH
  • then, in an intermediate attempt, as the full block post-state root

Neither matches what envelope import validates against.

This patch caches an envelope-specific state root derived from the real postState returned by block production and returns that root from getExecutionPayloadEnvelope().

Root cause

The validator API constructs self-build Gloas envelopes after block production, but the original cached produce result did not retain the correct envelope-level post-state root.

That led to a mismatch between:

  • the envelope stateRoot published by the validator API, and
  • the post-envelope root recomputed during import-time processExecutionPayloadEnvelope(...)

Fix

  • extend cached post-Gloas produce result with envelopeStateRoot
  • derive envelopeStateRoot from the real postState returned by computeNewStateRoot()
  • compute it by running processExecutionPayloadEnvelope(postState, signedEnvelope, {verifySignature: false, verifyStateRoot: false})
  • return envelopeStateRoot from getExecutionPayloadEnvelope()

Tests

Added:

  • packages/beacon-node/test/unit/api/impl/validator/getExecutionPayloadEnvelope.test.ts
    • proves the validator API returns the cached Gloas envelope state root
  • packages/beacon-node/test/e2e/chain/gloasEnvelopeSelfBuildImport.test.ts
    • deterministic acceptance path:
      1. produce Gloas block through validator API
      2. import block into forkchoice
      3. fetch self-build envelope
      4. import envelope through chain.processExecutionPayload(..., {validSignature: true})
      5. succeeds without the old state-transition mismatch

Validation run

  • pnpm test:unit packages/beacon-node/test/unit/api/impl/validator/getExecutionPayloadEnvelope.test.ts
  • pnpm exec vitest run packages/beacon-node/test/e2e/chain/gloasEnvelopeSelfBuildImport.test.ts

Notes

This PR intentionally carries only the minimal fix and focused coverage, not the broader root-cause investigation / runtime repro history.

@lodekeeper lodekeeper requested a review from a team as a code owner April 22, 2026 02:51
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements the calculation and caching of the envelopeStateRoot for self-produced blocks in the Gloas fork. Key changes include updating the ProduceFullGloas type, modifying the block production flow in BeaconChain to compute the state root after processing the execution payload envelope, and exposing this root via the validator API. Several tests were added to verify the correct handling of self-build envelopes. A review comment identified critical issues in the implementation of the state root calculation, including incorrect function invocation, missing imports, and type mismatches, providing a comprehensive suggestion to fix these errors.

Comment on lines +1077 to +1093
if (isForkPostGloas(fork)) {
const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue();
signedEnvelope.message = {
payload: (produceResult as ProduceFullGloas).executionPayload,
executionRequests: (produceResult as ProduceFullGloas).executionRequests,
builderIndex: BUILDER_INDEX_SELF_BUILD,
beaconBlockRoot: blockRoot,
slot,
stateRoot: ZERO_HASH,
};
const postEnvelopeState = postState.processExecutionPayloadEnvelope(signedEnvelope, {
verifySignature: false,
verifyStateRoot: false,
dontTransferCache: true,
});
(produceResult as ProduceFullGloas).envelopeStateRoot = postEnvelopeState.hashTreeRoot();
}
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.

high

There are several issues in this block that will likely cause compilation or runtime errors:

  1. Incorrect Call Syntax and Missing Import: processExecutionPayloadEnvelope is called as a method on postState, but it is defined as a standalone function in packages/state-transition/src/block/processExecutionPayloadEnvelope.ts. It must be imported and called as a function.
  2. Type Mismatch: The processExecutionPayloadEnvelope function expects a CachedBeaconStateGloas as its first argument, but postState is typed as IBeaconStateView. A cast is required.
  3. Repetitive Casting: produceResult is cast to ProduceFullGloas multiple times. It is cleaner to cast it once to a local variable.
  4. Object Creation: Using a plain object for signedEnvelope is more efficient and idiomatic here than using ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue() and then mutating it.

Note: You must add the following imports from @lodestar/state-transition (which are currently missing in this file):

import {processExecutionPayloadEnvelope, type CachedBeaconStateGloas} from "@lodestar/state-transition";
Suggested change
if (isForkPostGloas(fork)) {
const signedEnvelope = ssz.gloas.SignedExecutionPayloadEnvelope.defaultValue();
signedEnvelope.message = {
payload: (produceResult as ProduceFullGloas).executionPayload,
executionRequests: (produceResult as ProduceFullGloas).executionRequests,
builderIndex: BUILDER_INDEX_SELF_BUILD,
beaconBlockRoot: blockRoot,
slot,
stateRoot: ZERO_HASH,
};
const postEnvelopeState = postState.processExecutionPayloadEnvelope(signedEnvelope, {
verifySignature: false,
verifyStateRoot: false,
dontTransferCache: true,
});
(produceResult as ProduceFullGloas).envelopeStateRoot = postEnvelopeState.hashTreeRoot();
}
if (isForkPostGloas(fork)) {
const fullGloasResult = produceResult as ProduceFullGloas;
const signedEnvelope: gloas.SignedExecutionPayloadEnvelope = {
message: {
payload: fullGloasResult.executionPayload,
executionRequests: fullGloasResult.executionRequests,
builderIndex: BUILDER_INDEX_SELF_BUILD,
beaconBlockRoot: blockRoot,
slot,
stateRoot: ZERO_HASH,
},
signature: new Uint8Array(96),
};
const postEnvelopeState = processExecutionPayloadEnvelope(postState as CachedBeaconStateGloas, signedEnvelope, {
verifySignature: false,
verifyStateRoot: false,
dontTransferCache: true,
});
fullGloasResult.envelopeStateRoot = postEnvelopeState.hashTreeRoot();
}

@lodekeeper
Copy link
Copy Markdown
Contributor Author

Closing — this PR was opened on a false premise by an autonomous investigation.

The stateRoot: ZERO_HASH in getExecutionPayloadEnvelope() is intentional scaffolding, not a bug. See packages/beacon-node/src/api/impl/validator/index.ts:1652-1654:

// TODO GLOAS: stateRoot is no longer computed during block production.
// This field will be removed when we implement defer payload processing
stateRoot: ZERO_HASH,

The fix here computes an envelope stateRoot via processExecutionPayloadEnvelope(postState, ...) — exactly the computation deferred-payload-processing is designed to remove. It runs counter to the planned spec direction.

The real v1.42.0 "Withdrawal mismatch at index=0" regression is addressed by #9246 (loadState cache aliasing).

Apologies for the noise.

@lodekeeper lodekeeper closed this Apr 22, 2026
@lodekeeper lodekeeper deleted the fix/gloas-envelope-state-root branch April 22, 2026 04:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant