Skip to content

fix(consensus-any): saturate baseFeePerGas above u64::MAX on deser (#3741) - #3976

Merged
mattsse merged 2 commits into
alloy-rs:mainfrom
satyakwok:fix-anyheader-base-fee-saturating-3741
May 12, 2026
Merged

mattsse merged 2 commits into
alloy-rs:mainfrom
satyakwok:fix-anyheader-base-fee-saturating-3741

Conversation

@satyakwok

Copy link
Copy Markdown
Contributor

Closes #3741.

Bug

The Ethereum execution spec defines base_fee_per_gas as arbitrary-precision (Uint), but alloy types AnyHeader::base_fee_per_gas as Option<u64> for compatibility with Ethereum mainnet, which has never observed a value above u64::MAX. Some EVM-compatible chains do — Stable (chain id 988) returns baseFeePerGas = 0x3635c9adc5dea00000 (10²¹) for a range of historical blocks, causing RPC deserialization to fail with:

invalid type: integer `1000000000000000000000`, expected u64

Fix

Per maintainer-blessed direction (@mattsse in the issue thread):

would simply saturating this on deser would be an option?

Keep Option<u64> typing and saturate above u64::MAX on deserialize instead of erroring. New saturating_base_fee_per_gas serde adapter:

  • Serialize: delegates to alloy_serde::quantity::opt::serialize — no behavioral change on output
  • Deserialize: parses through Option<U256> then try_into::<u64>() with unwrap_or(u64::MAX) — saturates oversize, preserves None
pub fn deserialize<de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
    D: Deserializer<de>,
{
    let opt: Option<U256> = Option::deserialize(deserializer)?;
    Ok(opt.map(|v| v.try_into().unwrap_or(u64::MAX)))
}

Field-level doc records the rationale + points consumers needing full precision toward a custom header type.

Tests

Four new sub-tests under block::header::tests:

Test Input Expected
deserializes_base_fee_within_u64 0x3b9aca00 (1 gwei) Some(1_000_000_000)
deserializes_base_fee_saturates_above_u64 0x3635c9adc5dea00000 (Stable chain 10²¹) Some(u64::MAX)
deserializes_base_fee_exact_u64_max 0xffffffffffffffff Some(u64::MAX) (no truncation)
deserializes_base_fee_absent (field omitted) None (pre-London headers)
running 5 tests
test block::header::tests::deserializes_base_fee_absent ... ok
test block::header::tests::deserializes_base_fee_saturates_above_u64 ... ok
test block::header::tests::deserializes_tron_state_root_in_header ... ok
test block::header::tests::deserializes_base_fee_within_u64 ... ok
test block::header::tests::deserializes_base_fee_exact_u64_max ... ok

test result: ok. 5 passed; 0 failed

(Pre-existing deserializes_tron_state_root_in_header continues to pass.)

Compatibility

  • In-range values: unchanged behavior, same hex round-trip.
  • Pre-London (no base fee field): unchanged, still None.
  • Out-of-u64-range values: previously errored, now saturate. Any consumer that was relying on the deser error to detect oversize values will instead see u64::MAX — consumers that need the full-precision value should construct a custom header type with a wider field (now documented at the field level).

Open questions

  • Should I also apply the same saturating treatment to blob_gas_used / excess_blob_gas? The execution spec lists those as U64 (not Uint), so the spec-aligned answer is no — the strict u64 typing matches intent. Happy to follow up if the team wants those relaxed too.

…lloy-rs#3741)

The Ethereum execution spec defines base_fee_per_gas as arbitrary-precision
(Uint), but alloy types AnyHeader::base_fee_per_gas as Option<u64> for
compatibility with Ethereum mainnet, which has never observed a value above
u64::MAX. Some EVM-compatible chains do — Stable (chain id 988) returns
baseFeePerGas = 0x3635c9adc5dea00000 (10^21) for a range of historical
blocks, causing RPC deserialization to fail with:

  invalid type: integer `1000000000000000000000`, expected u64

Per the maintainer-blessed direction in the issue: keep Option<u64> typing
and saturate above u64::MAX on deserialize instead of erroring. Custom
serde adapter saturating_base_fee_per_gas reuses
alloy_serde::quantity::opt for serialize (no behavioral change on
output) and parses through Option<U256> on deserialize with try_into
falling back to u64::MAX.

Tests cover:
- in-range hex (1 Gwei)
- saturating above u64::MAX (Stable chain 10^21 case)
- exactly u64::MAX (no truncation)
- absent (preserves None for pre-London headers)

Consumers needing full precision should construct a custom header type
with a wider field; this is documented in the field-level doc.

Closes alloy-rs#3741

@mattsse mattsse left a comment

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.

Looks good now. I pushed a follow-up commit to keep the saturating path limited to human-readable serde, preserve the existing binary serde shape, and cover that with a bincode regression test.

@github-project-automation github-project-automation Bot moved this to Reviewed in Alloy May 12, 2026
@mattsse
mattsse merged commit 0d4a278 into alloy-rs:main May 12, 2026
28 of 30 checks passed
@github-project-automation github-project-automation Bot moved this from Reviewed to Done in Alloy May 12, 2026
@satyakwok

Copy link
Copy Markdown
Contributor Author

Sharp split — JSON sees untrusted RPC where saturate is the sane fallback, but binary overflow inside our own storage = corruption bug, fail-loud beats silent-clamp. Good call on the bincode regression test pinning that boundary; would've drifted quietly without it.

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

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

[Bug] AnyHeader fails to deserialize baseFeePerGas values exceeding u64::MAX

2 participants