Skip to content
Open
31 changes: 27 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ jobs:
id: lean-spec
run: echo "commit=$(sed -n 's/^LEAN_SPEC_COMMIT_HASH:= *//p' Makefile)" >> $GITHUB_OUTPUT

- name: Cache test fixtures
- name: Restore test fixtures cache
id: cache-fixtures
uses: actions/cache@v4
uses: actions/cache/restore@v5
with:
path: leanSpec/fixtures
key: leanspec-fixtures-${{ steps.lean-spec.outputs.commit }}
Expand Down Expand Up @@ -90,10 +90,10 @@ jobs:
HASH=$(echo -n "$URL" | sha256sum | awk '{print $1}')
echo "hash=$HASH" >> $GITHUB_OUTPUT

- name: Cache production keys
- name: Restore production keys cache
if: steps.cache-fixtures.outputs.cache-hit != 'true'
id: cache-prod-keys
uses: actions/cache@v4
uses: actions/cache/restore@v5
with:
path: leanSpec/packages/testing/src/consensus_testing/test_keys/prod_scheme
key: prod-keys-${{ steps.prod-keys-url.outputs.hash }}
Expand All @@ -103,11 +103,34 @@ jobs:
working-directory: leanSpec
run: uv run python -m consensus_testing.keys --download --scheme prod

# Save production keys even if a later step fails, so a re-run does
# not have to re-download. See: https://github.com/actions/cache/tree/main/save#always-save-cache
#
# `cache-hit == 'false'` (rather than `!= 'true'`) only matches when
# the restore step actually ran and missed: when fixtures were already
# cached, the restore was skipped and `cache-hit` is empty, so save
# is skipped too.
- name: Save production keys cache
if: always() && steps.cache-prod-keys.outputs.cache-hit == 'false'
uses: actions/cache/save@v5
with:
path: leanSpec/packages/testing/src/consensus_testing/test_keys/prod_scheme
key: ${{ steps.cache-prod-keys.outputs.cache-primary-key }}

- name: Generate test fixtures
if: steps.cache-fixtures.outputs.cache-hit != 'true'
working-directory: leanSpec
run: uv run fill --fork=Devnet --scheme prod -o fixtures -n 2

# Save fixtures even if a later step fails, so a re-run does not
# have to regenerate them. See: https://github.com/actions/cache/tree/main/save#always-save-cache
- name: Save test fixtures cache
if: always() && steps.cache-fixtures.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
with:
path: leanSpec/fixtures
key: ${{ steps.cache-fixtures.outputs.cache-primary-key }}

# Ensure make sees fixtures as up-to-date (its timestamp must be
# newer than leanSpec/, which intermediate steps may have modified).
- name: Mark fixtures as up-to-date
Expand Down
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ docker-build: ## 🐳 Build the Docker image
-t ghcr.io/lambdaclass/ethlambda:$(DOCKER_TAG) .
@echo

# 2026-04-20
LEAN_SPEC_COMMIT_HASH:=bc17f7ae8d16caec276f4d304e04fd3c65e6de3c
# 2026-04-28: bump for leanSpec PR #682 (validate_attestation future-slot bound).
LEAN_SPEC_COMMIT_HASH:=62eff6e7e6041a283877a546a07cb3b83f4f7d5b

leanSpec:
git clone https://github.com/leanEthereum/leanSpec.git --single-branch
Expand Down
8 changes: 8 additions & 0 deletions crates/blockchain/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ pub const MILLISECONDS_PER_SLOT: u64 = MILLISECONDS_PER_INTERVAL * INTERVALS_PER
///
/// See: leanSpec commit 0c9528a (PR #536).
pub const MAX_ATTESTATIONS_DATA: usize = 16;
/// Future-slot tolerance for gossip attestations, expressed in intervals.
///
/// Bounds the clock skew the time check is willing to absorb when admitting a
/// vote whose slot has not yet started locally. One interval is roughly 800 ms,
/// the lean analogue of mainnet's `MAXIMUM_GOSSIP_CLOCK_DISPARITY`.
///
/// See: leanSpec PR #682.
pub const GOSSIP_DISPARITY_INTERVALS: u64 = 1;

impl BlockChain {
pub fn spawn(
Expand Down
23 changes: 13 additions & 10 deletions crates/blockchain/src/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ use ethlambda_types::{
use tracing::{info, trace, warn};

use crate::{
INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA, MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT,
metrics,
GOSSIP_DISPARITY_INTERVALS, INTERVALS_PER_SLOT, MAX_ATTESTATIONS_DATA,
MILLISECONDS_PER_INTERVAL, MILLISECONDS_PER_SLOT, metrics,
};

const JUSTIFICATION_LOOKBACK_SLOTS: u64 = 3;
Expand Down Expand Up @@ -135,7 +135,7 @@ fn update_safe_target(store: &mut Store) {
/// 2. A vote cannot span backwards in time (source > target).
/// 3. The head must be at least as recent as source and target.
/// 4. Checkpoint slots must match the actual block slots.
/// 5. A vote cannot be for a future slot.
/// 5. The vote's slot must have started locally (a small disparity margin is allowed).
fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<(), StoreError> {
let _timing = metrics::time_attestation_validation();

Expand Down Expand Up @@ -182,13 +182,16 @@ fn validate_attestation_data(store: &Store, data: &AttestationData) -> Result<()
});
}

// Time Check - Validate attestation is not too far in the future.
// We allow a small margin for clock disparity (1 slot), but no further.
let current_slot = store.time() / INTERVALS_PER_SLOT;
if data.slot > current_slot + 1 {
// Time Check - Honest validators emit votes only after their slot has begun.
// Allow a small disparity margin for clock skew between peers.
//
// The bound is in intervals, not slots: a whole-slot margin would let an
// adversary pre-publish next-slot aggregates ahead of any honest validator.
let attestation_start_interval = data.slot.saturating_mul(INTERVALS_PER_SLOT);
if attestation_start_interval > store.time() + GOSSIP_DISPARITY_INTERVALS {
return Err(StoreError::AttestationTooFarInFuture {
attestation_slot: data.slot,
current_slot,
store_time: store.time(),
});
}

Expand Down Expand Up @@ -802,11 +805,11 @@ pub enum StoreError {
},

#[error(
"Attestation slot {attestation_slot} is too far in future (current slot: {current_slot})"
"Attestation slot {attestation_slot} is too far in future (store time: {store_time} intervals)"
)]
AttestationTooFarInFuture {
attestation_slot: u64,
current_slot: u64,
store_time: u64,
},

#[error(
Expand Down
8 changes: 8 additions & 0 deletions crates/blockchain/state_transition/tests/stf_spectests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ fn run(path: &Path) -> datatest_stable::Result<()> {
}
println!("Running test: {}", name);

// Fixtures with no blocks come from spec filler runs that raised
// before any block was constructed (e.g. negative tests where
// `state.process_slots(spec.slot)` aborts pre-build). With nothing
// for ethlambda to replay, the spec framework's verdict stands.
if test.blocks.is_empty() {
continue;
}

let mut pre_state: State = test.pre.into();
let mut result = Ok(());

Expand Down