Skip to content

feat(p2p): refuse execution payloads whose block hash is not their header's - #770

Draft
MegaRedHand wants to merge 2 commits into
mainfrom
feat/gossip-payload-block-hash
Draft

MegaRedHand wants to merge 2 commits into
mainfrom
feat/gossip-payload-block-hash

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

What

Beacon p2p now rebuilds the execution block header from a payload's own fields and REJECTs the message when keccak256(rlp(header)) is not the payload's block_hash:

  • pre-gloas beacon_block gossip (bellatrix to fulu), in triage_block, after the cheap rules
  • gloas execution_payload gossip, in triage_envelope
  • every envelope fetched by root or range (envelope_checks::judge)

The check lives in crates/net/p2p/src/beacon/payload_hash.rs (verify_block, verify_envelope). New RejectReason::PayloadBlockHashMismatch (payload_block_hash_mismatch), counter lean_beacon_payload_block_hash_mismatch_total{kind} and a warn! with slot and root.

It sits in p2p, not in state_transition::gossip::*, so the spec-fixture gossip vectors (which carry arbitrary payload hashes) are unaffected. Header encoding is done here with ethrex-rlp's Encoder, because ethrex's BlockHeader holds base_fee_per_gas as u64. Trie roots reuse ethrex-common (withdrawals, requests hash) and ethrex-trie (transactions over raw bytes; same tag v25.0.0, already in the lockfile, no new crate).

Why

Since engine_newPayloadV2 the Engine API answers a block-hash mismatch with INVALID and a null latestValidHash, indistinguishable from an executed-invalid payload whose last valid ancestor is unknown. Upcoming work applies payloads to fork choice before the EL answers; catching the structural case here keeps those payloads from being applied.

Deviation

Stricter than the consensus-specs gossip rules. Documented in docs/spec_deviations.md ("A payload's block hash must be its header's") and docs/beacon_wire.md. Blocks fetched via beacon_blocks_by_range/root are not checked.

Header per fork

bellatrix: London fields; capella: withdrawals_root; deneb: blob_gas_used, excess_blob_gas, parent_beacon_block_root (the block's parent_root); electra/fulu: requests_hash (from the Engine API request list); gloas: block_access_list_hash (keccak of the payload's BAL bytes, EIP-7928) and slot_number (EIP-7843), parent root from the envelope. A bellatrix block with the empty default payload (pre-merge) is skipped.

Real-chain vectors (SSZ, crates/net/p2p/src/beacon/testdata/payload_hash/)

Fork Chain Object Source
bellatrix Sepolia block, slot 500000 lodestar-sepolia.chainsafe.io
capella Sepolia block, slot 1900000 (carries withdrawals) lodestar-sepolia.chainsafe.io
deneb Hoodi block, slot 2003 lodestar-hoodi.chainsafe.io
electra Hoodi block, slot 70001 lodestar-hoodi.chainsafe.io
fulu mainnet block, slot 15576000 ethlambda-4 beacon API
gloas Platåberget envelope, slot 593101 ethlambda-3 beacon API

Each vector must match, and per fork a changed field (gas_used, block_hash, withdrawal amount, excess_blob_gas, parent root, transaction byte, extra_data, an extra request, slot_number, BAL byte, base fee, ...) must be refused. A triage-level test covers the gossip reject.

Testing

make fmt, make lint, cargo test -p ethlambda-p2p --profile release-fast --lib (372 passed), cargo test -p ethlambda-state-transition --profile release-fast --lib gossip. Existing p2p tests that forwarded structurally arbitrary envelopes now seal them with a test-only seal_envelope. No leanSpec or consensus-spec fixtures were run.

…ader's

Rebuild the execution block header from a payload's own fields and reject
the message when keccak256(rlp(header)) differs from the claimed
block_hash. Runs on pre-gloas beacon_block gossip (bellatrix to fulu), on
gloas execution_payload gossip, and on every envelope fetched by root or
range.

Since engine_newPayloadV2 the Engine API answers a block-hash mismatch
with INVALID and a null latestValidHash, indistinguishable from an
executed-invalid payload whose last valid ancestor is unknown. Catching
the structural case on the consensus side keeps such payloads from being
applied to fork choice ahead of the execution client's answer.

The header is encoded in ethlambda-p2p rather than through ethrex's
BlockHeader, which holds base_fee_per_gas as a u64 where the payload has
256 bits. Each fork is tested against a real block or envelope (Sepolia,
Hoodi, mainnet, Plataberget) plus a one-field-changed refusal.

This is stricter than the consensus specs' gossip rules, documented in
docs/spec_deviations.md and docs/beacon_wire.md.
MegaRedHand added a commit that referenced this pull request Nov 5, 2026
…ader's (#770)

Rebuild the execution block header from a payload's own fields and reject
the message when keccak256(rlp(header)) differs from the claimed
block_hash. Runs on pre-gloas beacon_block gossip (bellatrix to fulu), on
gloas execution_payload gossip, and on every envelope fetched by root or
range.

Since engine_newPayloadV2 the Engine API answers a block-hash mismatch
with INVALID and a null latestValidHash, indistinguishable from an
executed-invalid payload whose last valid ancestor is unknown. Catching
the structural case on the consensus side keeps such payloads from being
applied to fork choice ahead of the execution client's answer.

The header is encoded in ethlambda-p2p rather than through ethrex's
BlockHeader, which holds base_fee_per_gas as a u64 where the payload has
256 bits. Each fork is tested against a real block or envelope (Sepolia,
Hoodi, mainnet, Plataberget) plus a one-field-changed refusal.

This is stricter than the consensus specs' gossip rules, documented in
docs/spec_deviations.md and docs/beacon_wire.md.

Cherry-picked from feat/gossip-payload-block-hash at 686b481.

Claude-Session: https://claude.ai/code/session_011vghNmndKPkZinHRkVTwVB

This branch has not been deployed

No deployments
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