Skip to content

feat(types,evm)!: typed signet headers with invariant validation#223

Merged
prestwich merged 13 commits intomainfrom
prestwich/memoized-tx-receipts-root
Apr 4, 2026
Merged

feat(types,evm)!: typed signet headers with invariant validation#223
prestwich merged 13 commits intomainfrom
prestwich/memoized-tx-receipts-root

Conversation

@prestwich
Copy link
Copy Markdown
Member

@prestwich prestwich commented Apr 2, 2026

Summary

Introduces SignetHeaderV1 and SignetHeaderV2 validated newtypes over
Sealed<Header> to prevent downstream confusion between rootless and
rooted headers at the type level.

  • SignetHeaderV1: wraps Sealed<Header>, eagerly caches block hash.
    Validates that transactions_root and receipts_root are EMPTY_ROOT_HASH
    (the empty trie hash — preserving original behavior). Always available.
  • SignetHeaderV2: same wrapper, but validates roots are not
    EMPTY_ROOT_HASH. Behind #[cfg(feature = "experimental")] and
    #[deprecated] — unstable, not yet used in production.
  • Both types validate 7 shared "must be default" fields (ommers_hash,
    state_root, withdrawals_root, blob_gas_used, excess_blob_gas,
    requests_hash, extra_data) on construction via TryFrom<Header>.
  • SignetHeaderError reports all violations in both directions
    (must_be_default + must_not_be_default) in a single error.

Design Decisions

Why newtypes over Sealed<Header> (not Header)? The block hash is always
needed downstream. Eagerly sealing on construction avoids repeated hashing and
removes the need for separate SealedSignetHeaderV1 type aliases.

Why EMPTY_ROOT_HASH (not B256::ZERO) for V1? Alloy's Header::default()
sets roots to EMPTY_ROOT_HASH (keccak of the empty trie). The original
construct_header used ..Default::default() which produced EMPTY_ROOT_HASH
roots. V1 mandates this to preserve original behavior.

Why V2 only checks != EMPTY_ROOT_HASH? A zero root is not a valid computed
root in practice. The check is intentionally minimal — it distinguishes "someone
computed real roots" from "roots were left at the default empty trie value."

Why feature-gated + deprecated? V2 is the future direction but the root
computation path is not yet stabilized. The experimental feature gate prevents
accidental adoption. The #[deprecated] warning ensures anyone who opts in
knows the API is unstable.

Breaking Changes

  • SealedHeader type alias (Sealed<Header>) removed from public API
  • SealedBlock.header field type changed from SealedHeader to SignetHeaderV1
  • SignetDriver::new() accepts SignetHeaderV1 instead of SealedHeader
  • SignetDriver::parent() returns &SignetHeaderV1
  • BlockResult::header() and BlockResult::journal_meta() are no longer const fn
  • Test headers that set non-default values for validated fields (e.g. difficulty,
    mix_hash, nonce) have been stripped to pass V1 validation

Changes by Crate

signet-types

  • New header.rs module with SignetHeaderV1, SignetHeaderV2, SignetHeaderError
  • SealedBlock now stores SignetHeaderV1 instead of Sealed<Header>
  • experimental feature added

signet-evm

  • SignetDriver parent field and constructor use SignetHeaderV1
  • construct_header split into construct_header_v1() (always) and
    construct_header_v2() (experimental)
  • experimental feature forwards to signet-types/experimental
  • Memoized transactions_root via OnceLock seal/unseal pattern (from earlier commits)

signet-test-utils

  • All test header construction migrated to SignetHeaderV1::try_from()

Test Plan

  • cargo t -p signet-types --all-features — 54 pass (V1 + V2 validation tests)
  • cargo t -p signet-test-utils — all pass
  • cargo clippy -p signet-types --all-features --all-targets — clean
  • cargo clippy -p signet-types --no-default-features --all-targets — clean
  • cargo clippy -p signet-evm --all-features --all-targets — clean
  • cargo clippy -p signet-evm --no-default-features --all-targets — clean
  • cargo clippy -p signet-test-utils --all-features --all-targets — clean

ENG-2120

🤖 Generated with Claude Code

@prestwich prestwich requested a review from a team as a code owner April 2, 2026 18:10
@prestwich prestwich marked this pull request as draft April 2, 2026 18:36
prestwich and others added 2 commits April 3, 2026 08:36
Add OnceLock-based memoization for transactions_root using the
seal/unseal pattern. Wire receipts_root from trevm's new
BlockOutput::receipt_root() accessor. Both roots are now set
explicitly in construct_header instead of relying on Default.

BREAKING: requires trevm >=0.34.2 (init4tech/trevm#155)

ENG-2120

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace pub(crate) unseal with a private method and expose a
processed_mut() accessor that unseals and returns &mut Vec for
cross-module use.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prestwich prestwich force-pushed the prestwich/memoized-tx-receipts-root branch from 601663b to bff7ba2 Compare April 3, 2026 12:36
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
prestwich and others added 7 commits April 3, 2026 09:25
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Introduces validated newtype wrapping Sealed<Header> with invariant
checks on construction. Removes SealedHeader from public API.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dHeader

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…tal V2 constructor

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Preserves original behavior where rootless headers have roots set to
the empty trie hash (keccak256 of RLP-encoded empty list).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prestwich prestwich changed the title feat(evm)!: memoize transactions_root and receipts_root on SignetDriver feat(types,evm)!: typed signet headers with invariant validation Apr 3, 2026
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prestwich prestwich requested a review from Fraser999 April 3, 2026 13:56
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prestwich prestwich marked this pull request as ready for review April 3, 2026 13:59
Copy link
Copy Markdown
Contributor

@Fraser999 Fraser999 left a comment

Choose a reason for hiding this comment

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

One non-blocking suggestion.


/// Check that shared fields equal their defaults.
pub(crate) fn check_shared_defaults(header: &Header) -> Vec<&'static str> {
let d = Header::default();
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.

Minor point: we could use a static DEFAULT_HEADER: LazyLock<Header> = LazyLock::new(Header::default); to avoid allocating each time here.

Use a static `LazyLock<Header>` in `check_shared_defaults` to avoid
allocating a default Header on every call. Gate `check_roots_non_empty`
behind `cfg(feature = "experimental")` to match its only caller.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@prestwich prestwich enabled auto-merge (squash) April 4, 2026 23:06
@prestwich prestwich merged commit 0fea68e into main Apr 4, 2026
6 checks passed
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.

2 participants