diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3bede386..2d23a380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }} @@ -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 }} @@ -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 diff --git a/Makefile b/Makefile index c9c1c17a..013b1573 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/crates/blockchain/src/lib.rs b/crates/blockchain/src/lib.rs index 77c98826..5e47f2ed 100644 --- a/crates/blockchain/src/lib.rs +++ b/crates/blockchain/src/lib.rs @@ -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( diff --git a/crates/blockchain/src/store.rs b/crates/blockchain/src/store.rs index 45b3b220..66baf2c9 100644 --- a/crates/blockchain/src/store.rs +++ b/crates/blockchain/src/store.rs @@ -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; @@ -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(); @@ -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(), }); } @@ -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( diff --git a/crates/blockchain/state_transition/tests/stf_spectests.rs b/crates/blockchain/state_transition/tests/stf_spectests.rs index 5c25c8a1..669ea835 100644 --- a/crates/blockchain/state_transition/tests/stf_spectests.rs +++ b/crates/blockchain/state_transition/tests/stf_spectests.rs @@ -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(());