From 10f62f558c54f6864df830ad657df71b62e7e758 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Fri, 17 Apr 2026 18:31:07 +0800 Subject: [PATCH 1/9] fix: align attestation future slot tolerance with leanSpec leanSpec store.py:320 allows current_slot + 1 for all attestations (gossip and block) as clock disparity tolerance. zeam previously only allowed +1 for block attestations and rejected gossip attestations for future slots. Align with leanSpec's unified behavior. --- pkgs/node/src/chain.zig | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 88c407fcd..9f7586e50 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1498,14 +1498,10 @@ pub const BeamChain = struct { // 4. Validate attestation is not too far in the future // - // Gossip attestations must be for current or past slots only. Validators attest - // in interval 1 of the current slot, so they cannot attest for future slots. - // Block attestations can be more lenient since the block itself was validated. + // Per leanSpec: allow current_slot + 1 for clock disparity tolerance, + // regardless of whether the attestation is from gossip or a block. const current_slot = self.forkChoice.getCurrentSlot(); - const max_allowed_slot = if (is_from_block) - current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE // Block attestations: allow +1 - else - current_slot; // Gossip attestations: no future slots allowed + const max_allowed_slot = current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE; if (data.slot > max_allowed_slot) { self.logger.debug("attestation validation failed: attestation slot {d} > max allowed slot {d} (is_from_block={any})", .{ @@ -2408,12 +2404,9 @@ test "attestation validation - gossip vs block future slot handling" { .signature = ZERO_SIGBYTES, }; - // Gossip attestations: should FAIL for next slot (current + 1) - // Per spec store.py:177: assert attestation.slot <= time_slots - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(next_slot_attestation.message, false)); - - // Block attestations: should PASS for next slot (current + 1) - // Per spec store.py:140: assert attestation.slot <= Slot(current_slot + Slot(1)) + // Per leanSpec store.py:320: assert data.slot <= current_slot + Slot(1) + // Both gossip and block attestations allow current_slot + 1 + try beam_chain.validateAttestationData(next_slot_attestation.message, false); try beam_chain.validateAttestationData(next_slot_attestation.message, true); const too_far_attestation: types.SignedAttestation = .{ .validator_id = 0, From 2c8083b409b04bc765680200f3500d661d991207 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Fri, 17 Apr 2026 19:15:50 +0800 Subject: [PATCH 2/9] fix: tolerate pruned source blocks in attestation validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When finalization advances, old blocks are pruned from the protoarray. Aggregations referencing a source checkpoint that was valid when the attestation was created can fail validation if finalization pruned the source block between attestation creation and aggregation publishing. Accept source blocks whose slot <= finalized_slot as valid even when absent from the protoarray — they were canonical and are now below the finalization horizon. Root cause: race between finalization pruning (interval 0 of new slot) and aggregation publishing (interval 2 of same slot), where the aggregation's attestation data references a justified checkpoint that got pruned during the finalization advance. --- pkgs/node/src/chain.zig | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 9f7586e50..9d52998bd 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1436,12 +1436,16 @@ pub const BeamChain = struct { defer _ = timer.observe(); // 1. Validate that source, target, and head blocks exist in proto array (thread-safe) - const source_block = self.forkChoice.getProtoNode(data.source.root) orelse { + // Source blocks that have been finalized and pruned from the protoarray are still valid — + // they were canonical at one point and are now below the finalization horizon. + const finalized_checkpoint = self.forkChoice.getLatestFinalized(); + const source_block = self.forkChoice.getProtoNode(data.source.root); + if (source_block == null and data.source.slot > finalized_checkpoint.slot) { self.logger.debug("Attestation validation failed: unknown source block root=0x{x}", .{ &data.source.root, }); return AttestationValidationError.UnknownSourceBlock; - }; + } const target_block = self.forkChoice.getProtoNode(data.target.root) orelse { self.logger.debug("attestation validation failed: unknown target block slot={d} root=0x{x}", .{ @@ -1461,9 +1465,12 @@ pub const BeamChain = struct { _ = head_block; // Will be used in future validations // 2. Validate slot relationships - if (source_block.slot > target_block.slot) { + // Use proto node slot when available, fall back to checkpoint slot for pruned sources + const source_slot = if (source_block) |sb| sb.slot else data.source.slot; + + if (source_slot > target_block.slot) { self.logger.debug("attestation validation failed: source slot {d} > target slot {d}", .{ - source_block.slot, + source_slot, target_block.slot, }); return AttestationValidationError.SourceSlotExceedsTarget; @@ -1478,13 +1485,15 @@ pub const BeamChain = struct { return AttestationValidationError.SourceCheckpointExceedsTarget; } - // 3. Validate checkpoint slots match block slots - if (source_block.slot != data.source.slot) { - self.logger.debug("attestation validation failed: source block slot {d} != source checkpoint slot {d}", .{ - source_block.slot, - data.source.slot, - }); - return AttestationValidationError.SourceCheckpointSlotMismatch; + // 3. Validate checkpoint slots match block slots (skip for pruned source blocks) + if (source_block) |sb| { + if (sb.slot != data.source.slot) { + self.logger.debug("attestation validation failed: source block slot {d} != source checkpoint slot {d}", .{ + sb.slot, + data.source.slot, + }); + return AttestationValidationError.SourceCheckpointSlotMismatch; + } } // This corresponds to leanSpec's: assert target_block.slot == attestation.target.slot From d62db9d2bcffddd4ce7439027d3ef1a3cb87bc68 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Fri, 17 Apr 2026 22:38:37 +0800 Subject: [PATCH 3/9] fix: remove attestation data validation from onGossipAggregatedAttestation Per leanSpec store.py on_gossip_aggregated_attestation: aggregated attestations only verify the aggregated signature proof, they do NOT call validate_attestation on the attestation data. nlean follows the same pattern. The attestation data was already validated when individual gossip attestations arrived. Re-validating in the aggregation path caused UnknownSourceBlock errors when finalization pruned the source block from protoarray between attestation creation and aggregation publishing. This also reverts the "tolerate pruned source blocks" workaround in validateAttestationData since the root cause is now fixed. --- pkgs/node/src/chain.zig | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 9d52998bd..396e725ff 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1436,16 +1436,12 @@ pub const BeamChain = struct { defer _ = timer.observe(); // 1. Validate that source, target, and head blocks exist in proto array (thread-safe) - // Source blocks that have been finalized and pruned from the protoarray are still valid — - // they were canonical at one point and are now below the finalization horizon. - const finalized_checkpoint = self.forkChoice.getLatestFinalized(); - const source_block = self.forkChoice.getProtoNode(data.source.root); - if (source_block == null and data.source.slot > finalized_checkpoint.slot) { + const source_block = self.forkChoice.getProtoNode(data.source.root) orelse { self.logger.debug("Attestation validation failed: unknown source block root=0x{x}", .{ &data.source.root, }); return AttestationValidationError.UnknownSourceBlock; - } + }; const target_block = self.forkChoice.getProtoNode(data.target.root) orelse { self.logger.debug("attestation validation failed: unknown target block slot={d} root=0x{x}", .{ @@ -1465,12 +1461,9 @@ pub const BeamChain = struct { _ = head_block; // Will be used in future validations // 2. Validate slot relationships - // Use proto node slot when available, fall back to checkpoint slot for pruned sources - const source_slot = if (source_block) |sb| sb.slot else data.source.slot; - - if (source_slot > target_block.slot) { + if (source_block.slot > target_block.slot) { self.logger.debug("attestation validation failed: source slot {d} > target slot {d}", .{ - source_slot, + source_block.slot, target_block.slot, }); return AttestationValidationError.SourceSlotExceedsTarget; @@ -1485,15 +1478,13 @@ pub const BeamChain = struct { return AttestationValidationError.SourceCheckpointExceedsTarget; } - // 3. Validate checkpoint slots match block slots (skip for pruned source blocks) - if (source_block) |sb| { - if (sb.slot != data.source.slot) { - self.logger.debug("attestation validation failed: source block slot {d} != source checkpoint slot {d}", .{ - sb.slot, - data.source.slot, - }); - return AttestationValidationError.SourceCheckpointSlotMismatch; - } + // 3. Validate checkpoint slots match block slots + if (source_block.slot != data.source.slot) { + self.logger.debug("attestation validation failed: source block slot {d} != source checkpoint slot {d}", .{ + source_block.slot, + data.source.slot, + }); + return AttestationValidationError.SourceCheckpointSlotMismatch; } // This corresponds to leanSpec's: assert target_block.slot == attestation.target.slot @@ -1546,8 +1537,9 @@ pub const BeamChain = struct { } pub fn onGossipAggregatedAttestation(self: *Self, signedAggregation: types.SignedAggregatedAttestation) !void { - // Validate the attestation data first (same rules as individual gossip attestations) - try self.validateAttestationData(signedAggregation.data, false); + // Per leanSpec: on_gossip_aggregated_attestation does NOT call validate_attestation. + // Attestation data was already validated when individual gossip attestations arrived. + // Re-validating here would fail after finalization prunes the source block from protoarray. try self.verifyAggregatedAttestation(signedAggregation); From 9b56ce7dbc8f67bb68f2a82264102511a38e049c Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Mon, 20 Apr 2026 23:21:36 +0800 Subject: [PATCH 4/9] fix(forkchoice): gate proto-array rebase on PRUNE_NODE_THRESHOLD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root-cause the attestation-race previously worked around in chain.onGossipAggregatedAttestation and validateAttestationData. Symptom: every time finalization advanced, processFinalizationAdvancement called forkChoice.rebase(...) unconditionally. Rebase drops pre-finalized ancestors from proto-array and remaps attestation-tracker indices. Any in-flight attestation whose source / target / head referenced one of those dropped blocks then failed validateAttestationData's existence check with Unknown{Source,Target,Head}Block — even though the vote was perfectly valid when the validator produced it. 3SF-mini's fast finalization cadence (one advance every few slots) puts the race window on the same order of magnitude as normal gossip propagation, so attestations drop across every finalization boundary under normal load. Sync catch-up, which can advance finalization multiple steps in a single tick, makes it much worse. Fix — lighthouse-style lazy prune: * Introduce constants.PRUNE_NODE_THRESHOLD = 64. Lighthouse uses 256 for mainnet; 3SF-mini's shorter wall-clock per finalization step makes 64 (~1 eth epoch worth of grace) the better trade between race coverage and memory footprint. * Gate the rebase call in processFinalizationAdvancement on the finalized node's index in the proto-array. While that index is below the threshold, skip rebase and leave the pre-finalized prefix addressable so in-flight attestations still resolve. * Expose ForkChoice.getProtoNodeIndex for the gate (mutex-safe wrapper over protoArray.indices). Revert of the prior tolerance workaround: * onGossipAggregatedAttestation now re-validates the attestation data again. The "skip validate" workaround exists only because source blocks could vanish mid-finalization; with the threshold gate keeping them in place, the stricter check is safe again and catches malformed aggregates that used to slip through. Test: * Add regression test driving the mock chain with default onBlock opts (pruneForkchoice = true) through 5 slots. The finalized node's index stays well under the threshold, so pre-finalized ancestors must remain resolvable via getProtoNode after finalization advances. Before this fix, the rebase call would have removed roots[1] and roots[2] (slots < latestFinalized.slot) from the protoArray and the assertion would trip. * Update the existing "Test 9: Attestation too far in future" case to use slot=current+2 — the spec-aligned +1 tolerance change from 09712ac3 made the prior slot=current+1 trip point pass validation; the test was not updated at the time. The future-slot +1 tolerance fix (09712ac3) is orthogonal and preserved — spec alignment with store.py:320. --- pkgs/node/src/chain.zig | 164 +++++++++++++++++++++++++++-------- pkgs/node/src/constants.zig | 18 ++++ pkgs/node/src/forkchoice.zig | 9 ++ 3 files changed, 156 insertions(+), 35 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 396e725ff..99eb82402 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1315,9 +1315,26 @@ pub const BeamChain = struct { pruned_count, }); - // 5 Rebase forkchouce - if (pruneForkchoice) - try self.forkChoice.rebase(latestFinalized.root, &canonical_view); + // 5 Rebase forkchoice — lazy prune with node-count threshold. + // + // Eager rebase drops pre-finalized ancestors from proto-array and + // remaps attestation-tracker indices. In-flight attestations whose + // source/target/head still points at one of those ancestors then + // fail the existence checks in validateAttestationData with + // Unknown{Source,Target,Head}Block, and the node burns bandwidth + // re-fetching blocks that will never come back. The grace window + // must outlast the worst-case gossip delay plus at least one + // finalization tick; constants.PRUNE_NODE_THRESHOLD = 64 slots + // (≈256 s at SECONDS_PER_SLOT=4) is comfortably beyond both. + if (pruneForkchoice) { + if (self.forkChoice.getProtoNodeIndex(latestFinalized.root)) |finalized_idx| { + if (finalized_idx >= constants.PRUNE_NODE_THRESHOLD) { + try self.forkChoice.rebase(latestFinalized.root, &canonical_view); + } + // else: threshold not reached; keep pre-finalized ancestors + // in proto-array so in-flight attestations still resolve. + } + } // TODO: // 6. Remove orphaned blocks from database and cleanup unfinalized indices of there are any @@ -1537,9 +1554,15 @@ pub const BeamChain = struct { } pub fn onGossipAggregatedAttestation(self: *Self, signedAggregation: types.SignedAggregatedAttestation) !void { - // Per leanSpec: on_gossip_aggregated_attestation does NOT call validate_attestation. - // Attestation data was already validated when individual gossip attestations arrived. - // Re-validating here would fail after finalization prunes the source block from protoarray. + // Validate attestation data (same rules as individual gossip attestations). + // The earlier revision of this function skipped re-validation because the + // source block could vanish from proto-array when finalization advanced + // between publish and receipt. That race is now handled at the root — + // processFinalizationAdvancement gates the rebase call on + // PRUNE_NODE_THRESHOLD, keeping the source / target / head blocks + // addressable for the full grace window — so the stricter check is + // safe again. + try self.validateAttestationData(signedAggregation.data, false); try self.verifyAggregatedAttestation(signedAggregation); @@ -2311,7 +2334,7 @@ test "attestation validation - comprehensive" { const future_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ - .slot = 3, // Future slot (current is 2) + .slot = 4, // Two slots past current (current is 2, tolerance is +1) .head = types.Checkpoint{ .root = mock_chain.blockRoots[2], .slot = 2, @@ -2384,11 +2407,12 @@ test "attestation validation - gossip vs block future slot handling" { const missing_roots = try beam_chain.onBlock(block, .{}); allocator.free(missing_roots); - // Current time is at slot 1, create attestation for slot 2 (next slot) + // Current time is at slot 1. Create attestation for slot 3 (current + 2), + // which exceeds the +1 tolerance for both gossip and block. const next_slot_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ - .slot = 2, + .slot = 3, .head = types.Checkpoint{ .root = mock_chain.blockRoots[1], .slot = 1, @@ -2405,32 +2429,13 @@ test "attestation validation - gossip vs block future slot handling" { .signature = ZERO_SIGBYTES, }; - // Per leanSpec store.py:320: assert data.slot <= current_slot + Slot(1) - // Both gossip and block attestations allow current_slot + 1 - try beam_chain.validateAttestationData(next_slot_attestation.message, false); - try beam_chain.validateAttestationData(next_slot_attestation.message, true); - const too_far_attestation: types.SignedAttestation = .{ - .validator_id = 0, - .message = .{ - .slot = 3, // Too far in future - .head = types.Checkpoint{ - .root = mock_chain.blockRoots[1], - .slot = 1, - }, - .source = types.Checkpoint{ - .root = mock_chain.blockRoots[0], - .slot = 0, - }, - .target = types.Checkpoint{ - .root = mock_chain.blockRoots[1], - .slot = 1, - }, - }, - .signature = ZERO_SIGBYTES, - }; - // Both should fail for slot 3 when current is slot 1 - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, false)); - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, true)); + // Gossip attestations: should FAIL for slot current + 2 + // Per spec store.py:177: assert attestation.slot <= time_slots + try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(next_slot_attestation.message, false)); + + // Block attestations: should FAIL for slot current + 2 + // Per spec store.py:140: assert attestation.slot <= Slot(current_slot + Slot(1)) + try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(next_slot_attestation.message, true)); } // TODO: Enable and update this test once the keymanager file-reading PR is added // JSON parsing for chain config needs to support validator_attestation_pubkeys instead of num_validators @@ -2660,3 +2665,92 @@ test "produceBlock - greedy selection by latest slot is suboptimal when attestat try std.testing.expect(unseen_count == 0); try std.testing.expect(known_count > 0); } + +test "processFinalizationAdvancement: below PRUNE_NODE_THRESHOLD keeps pre-finalized ancestors" { + // Regression: eager ProtoArray.rebase dropped pre-finalized ancestors on + // every finalization advance, so in-flight attestations whose source / + // target / head referenced those ancestors failed the existence check in + // validateAttestationData with Unknown{Source,Target,Head}Block. + // + // With the threshold gate, rebase is skipped while the finalized node's + // index in protoArray is below PRUNE_NODE_THRESHOLD. A short mock chain + // never crosses the threshold, so all recently-finalized ancestors must + // still be addressable after the chain finalizes. + var arena_allocator = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_allocator.deinit(); + const allocator = arena_allocator.allocator(); + + const mock_chain = try stf.genMockChain(allocator, 5, null); + const spec_name = try allocator.dupe(u8, "beamdev"); + const fork_digest = try allocator.dupe(u8, "12345678"); + const chain_config = configs.ChainConfig{ + .id = configs.Chain.custom, + .genesis = mock_chain.genesis_config, + .spec = .{ + .preset = params.Preset.mainnet, + .name = spec_name, + .fork_digest = fork_digest, + .attestation_committee_count = 1, + .max_attestations_data = 16, + }, + }; + var beam_state = mock_chain.genesis_state; + var zeam_logger_config = zeam_utils.getTestLoggerConfig(); + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + const data_dir = try tmp_dir.dir.realpathAlloc(allocator, "."); + defer allocator.free(data_dir); + + var db = try database.Db.open(allocator, zeam_logger_config.logger(.database_test), data_dir); + defer db.deinit(); + + const connected_peers = try allocator.create(std.StringHashMap(PeerInfo)); + connected_peers.* = std.StringHashMap(PeerInfo).init(allocator); + + const test_registry = try allocator.create(NodeNameRegistry); + defer allocator.destroy(test_registry); + test_registry.* = NodeNameRegistry.init(allocator); + defer test_registry.deinit(); + + var beam_chain = try BeamChain.init( + allocator, + ChainOpts{ + .config = chain_config, + .anchorState = &beam_state, + .nodeId = 7, + .logger_config = &zeam_logger_config, + .db = db, + .node_registry = test_registry, + }, + connected_peers, + ); + defer beam_chain.deinit(); + + // Drive the chain with default opts (pruneForkchoice = true) so the gate + // is actually exercised. + for (1..mock_chain.blocks.len) |i| { + const signed_block = mock_chain.blocks[i]; + const current_slot = signed_block.block.slot; + try beam_chain.forkChoice.onInterval(current_slot * constants.INTERVALS_PER_SLOT, false); + const missing_roots = try beam_chain.onBlock(signed_block, .{}); + allocator.free(missing_roots); + } + + // Sanity-check: mock chain must actually advance finalization for this + // regression to mean anything. + try std.testing.expect(beam_chain.forkChoice.getLatestFinalized().slot > 0); + + // The finalized node's index in protoArray should be well under the + // threshold (5 blocks total, threshold = 64). + const finalized_idx = beam_chain.forkChoice.getProtoNodeIndex(beam_chain.forkChoice.getLatestFinalized().root); + try std.testing.expect(finalized_idx != null); + try std.testing.expect(finalized_idx.? < constants.PRUNE_NODE_THRESHOLD); + + // All processed block roots — including the pre-finalized ones — must + // still resolve through the fork-choice API that validateAttestationData + // consults. Pre-gate, rebase would have dropped the below-finalized ones. + for (1..mock_chain.blocks.len) |i| { + try std.testing.expect(beam_chain.forkChoice.getProtoNode(mock_chain.blockRoots[i]) != null); + } +} diff --git a/pkgs/node/src/constants.zig b/pkgs/node/src/constants.zig index 105b771c3..90af5bc58 100644 --- a/pkgs/node/src/constants.zig +++ b/pkgs/node/src/constants.zig @@ -21,6 +21,24 @@ pub const MAX_CACHED_BLOCKS = 1024; // Set to 7200 slots (approximately 8 hours in Lean, assuming 4 seconds per slot) pub const FORKCHOICE_PRUNING_INTERVAL_SLOTS: u64 = 7200; +// Grace window before proto-array rebuild fires on finalization advance. +// +// Eager rebase drops the just-finalized block's pre-finalized ancestors from +// proto-array and remaps attestation-tracker indices. In-flight attestations +// whose source / target / head still references one of those dropped blocks +// then fail existence checks with Unknown{Source,Target,Head}Block even +// though they were valid at sign time. 3SF-mini's fast finalization cadence +// makes this race fire across normal gossip delay. +// +// Gate rebase on the finalized node's position in proto-array: only rebuild +// once there are at least PRUNE_NODE_THRESHOLD pre-finalized nodes sitting +// before the finalized anchor. Below that, leave the prefix in place so +// in-flight attestations still resolve their references. 64 slots at +// SECONDS_PER_SLOT=4 gives ≈256 s of grace — several orders of magnitude +// wider than any realistic gossip rtt while costing only ~64 extra +// proto-nodes (bounded, small) of memory. +pub const PRUNE_NODE_THRESHOLD: usize = 64; + // Forkchoice visualization constants pub const MAX_FC_DISPLAY_DEPTH = 100; pub const MAX_FC_DISPLAY_BRANCH = 10; diff --git a/pkgs/node/src/forkchoice.zig b/pkgs/node/src/forkchoice.zig index f8be9b768..621112143 100644 --- a/pkgs/node/src/forkchoice.zig +++ b/pkgs/node/src/forkchoice.zig @@ -1897,6 +1897,15 @@ pub const ForkChoice = struct { return self.protoArray.nodes.items[idx]; } + /// Get a ProtoNode's index in the underlying nodes array. Callers use + /// this to measure how many nodes precede a given block (e.g. to gate + /// proto-array rebase on a grace-window threshold). + pub fn getProtoNodeIndex(self: *Self, blockRoot: types.Root) ?usize { + self.mutex.lockShared(); + defer self.mutex.unlockShared(); + return self.protoArray.indices.get(blockRoot); + } + /// Get the current number of nodes in the forkchoice tree pub fn getNodeCount(self: *Self) usize { self.mutex.lockShared(); From 82115ac1bdab98602bbb77b718e045b57a01bb7a Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 22 Apr 2026 14:23:53 +0800 Subject: [PATCH 5/9] fix(forkchoice): drop time-based block queue; align with leanSpec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zeam was the only client among leanSpec / ream / qlean-mini / ethlambda / nlean that queued inbound blocks on the local clock. Every other implementation processes a block the moment its parent state is known — their only "pending" caches are for orphan blocks (parent unknown, awaiting backfill), never for blocks whose slot just hasn't started yet locally. The queue also kept block body attestations out of fork choice until the next interval tick, delaying head updates by a full interval and duplicating the future-slot rejection that already lives in chain.validateBlock. Changes: * Remove BeamChain.pending_blocks field, its init / deinit hooks, and the append site on the gossip path. * Remove BeamChain.processPendingBlocks (replay helper) and its caller in node.onInterval. * Drop the duplicate future-slot gate in forkchoice.onBlockUnlocked. Gossip-level DoS filtering continues to happen in chain.validateBlock (MAX_FUTURE_SLOT_TOLERANCE = 1, matching leanSpec store.py:320); forkchoice now mirrors leanSpec store.on_block and only requires a known parent plus non-pre-finalized slot. * Remove ForkChoiceError.FutureSlot — no caller returns it. * Update the fork-choice block-tree unit test: the old test drove "reject future slot" against forkchoice.onBlock; that responsibility has moved up to chain.validateBlock, so the test now just ticks the clock and processes the block. validateBlock still rejects block.slot > current_slot + 1 with BlockValidationError.FutureSlot; node.zig's handler for error.FutureSlot still caches those blocks for later retry, so the "block arrived genuinely too early" case is covered exactly as before — just without the redundant inner-layer queue. --- pkgs/node/src/chain.zig | 101 +++-------------------------------- pkgs/node/src/forkchoice.zig | 26 +++++---- pkgs/node/src/node.zig | 13 ----- 3 files changed, 24 insertions(+), 116 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 99eb82402..31d55547b 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -132,11 +132,6 @@ pub const BeamChain = struct { prune_cached_blocks_fn: ?PruneCachedBlocksFn = null, // Queue for blocks that arrived before forkchoice had ticked to their slot. - // When a peer gossips a block for the current slot before our local interval - // timer fires, the forkchoice rejects it with FutureSlot. We hold such - // blocks here and replay them in onInterval once the clock has caught up. - pending_blocks: std.ArrayList(types.SignedBlock), - pub const PruneCachedBlocksFn = *const fn (ptr: *anyopaque, finalized: types.Checkpoint) usize; const Self = @This(); @@ -182,7 +177,6 @@ pub const BeamChain = struct { .is_aggregator_enabled = std.atomic.Value(bool).init(opts.is_aggregator), .public_key_cache = xmss.PublicKeyCache.init(allocator), .root_to_slot_cache = types.RootToSlotCache.init(allocator), - .pending_blocks = .empty, }; // Initialize cache with anchor block root and any post-finalized entries from state try chain.root_to_slot_cache.put(fork_choice.head.blockRoot, opts.anchorState.slot); @@ -217,11 +211,6 @@ pub const BeamChain = struct { // Clean up root to slot cache self.root_to_slot_cache.deinit(); - // Clean up any blocks that were queued waiting for the forkchoice clock - for (self.pending_blocks.items) |*block| { - block.deinit(); - } - self.pending_blocks.deinit(self.allocator); // assume the allocator of config is same as self.allocator self.config.deinit(self.allocator); @@ -257,51 +246,6 @@ pub const BeamChain = struct { zeam_metrics.metrics.lean_validators_count.set(self.registered_validator_ids.len); } - /// Replay blocks that were queued because the forkchoice clock hadn't yet - /// reached their slot. Called from onInterval after advancing the clock. - /// Returns a slice of all missing attestation roots encountered while - /// processing queued blocks; the caller owns and must free the slice. - pub fn processPendingBlocks(self: *Self) []types.Root { - var all_missing_roots: std.ArrayListUnmanaged(types.Root) = .empty; - const fc_time = self.forkChoice.fcStore.slot_clock.time.load(.monotonic); - var i: usize = 0; - while (i < self.pending_blocks.items.len) { - const queued_slot = self.pending_blocks.items[i].block.slot; - if (queued_slot * constants.INTERVALS_PER_SLOT <= fc_time) { - // Remove from queue (ownership transferred to local var). - var queued_block = self.pending_blocks.orderedRemove(i); - defer queued_block.deinit(); - - var block_root: types.Root = undefined; - zeam_utils.hashTreeRoot(types.BeamBlock, queued_block.block, &block_root, self.allocator) catch |err| { - self.logger.err("queued block slot={d}: failed to compute block root: {any}", .{ queued_slot, err }); - continue; - }; - - self.logger.info( - "replaying queued block slot={d} blockroot=0x{x} (fc_time now={d})", - .{ queued_slot, &block_root, fc_time }, - ); - - const missing_roots = self.onBlock(queued_block, .{ - .blockRoot = block_root, - }) catch |err| { - self.logger.err("queued block slot={d} root=0x{x}: processing failed: {any}", .{ queued_slot, &block_root, err }); - continue; - }; - defer self.allocator.free(missing_roots); - - self.onBlockFollowup(true, &queued_block); - - // Accumulate missing roots so the caller can fetch them. - all_missing_roots.appendSlice(self.allocator, missing_roots) catch {}; - } else { - i += 1; - } - } - return all_missing_roots.toOwnedSlice(self.allocator) catch &.{}; - } - pub fn onInterval(self: *Self, time_intervals: usize) !void { // see if the node has a proposal this slot to properly tick // forkchoice head @@ -689,49 +633,18 @@ pub const BeamChain = struct { }); if (!hasBlock) { - // Validation errors propagate to node.zig for context-aware logging + // Validation errors propagate to node.zig for context-aware logging. + // validateBlock enforces the MAX_FUTURE_SLOT_TOLERANCE (+1) bound + // consistent with leanSpec store.py:320; anything beyond the bound + // is rejected as BlockValidationError.FutureSlot and cached by + // node.zig for later retry. Blocks within the tolerance are + // processed immediately, aligning with leanSpec/ream/qlean-mini/ + // ethlambda/nlean — none of which queue blocks on the local clock. try self.validateBlock(block, true); - // If the forkchoice clock hasn't yet ticked to this block's slot, - // onBlock would reject it with FutureSlot. Queue the block and - // replay it from onInterval once the clock has advanced. - if (block.slot * constants.INTERVALS_PER_SLOT > self.forkChoice.fcStore.slot_clock.time.load(.monotonic)) { - self.logger.debug( - "queuing gossip block slot={d} blockroot=0x{x}: forkchoice time={d} < slot_start={d}", - .{ block.slot, &block_root, self.forkChoice.fcStore.slot_clock.time.load(.monotonic), block.slot * constants.INTERVALS_PER_SLOT }, - ); - var cloned: types.SignedBlock = undefined; - try types.sszClone(self.allocator, types.SignedBlock, signed_block, &cloned); - - // TODO: in beam sim, it seems to have queued after the oninterval fires even if block arrives pre on interval - // because of race conditions between competing threads as the above sszClone aparently takes too much time - // currently managing this by checking condition again but ideally fix it by identifying chain entrypoints and - // holding mutex between then for chain modification sections - if (block.slot * constants.INTERVALS_PER_SLOT > self.forkChoice.fcStore.slot_clock.time.load(.monotonic)) { - try self.pending_blocks.append(self.allocator, cloned); - - self.logger.info( - "queued gossip block slot={d} blockroot=0x{x}: forkchoice time={d} < slot_start={d}", - .{ block.slot, &block_root, self.forkChoice.fcStore.slot_clock.time.load(.monotonic), block.slot * constants.INTERVALS_PER_SLOT }, - ); - return .{}; - } else { - self.logger.debug( - // - "chain already ticked while cloning block for queuing, skipping queuing and directly processing slot={d} blockroot=0x{x}: forkchoice time={d} < slot_start={d}", - // - .{ block.slot, &block_root, self.forkChoice.fcStore.slot_clock.time.load(.monotonic), block.slot * constants.INTERVALS_PER_SLOT }); - // by the time we cloned, chain ticked, so we can directly add and deinit clone - cloned.deinit(); - } - } - const missing_roots = self.onBlock(signed_block, .{ .blockRoot = block_root, }) catch |err| { - // we will not catch and enqueue block for FutureSlot error because this error here means - // that the block's slot is 2 ahead of the local because we have tolerance of 1 in case of - // clock skew or race between oninterval and block arrival self.logger.err("error processing block for slot={d} root=0x{x}: {any}", .{ block.slot, &block_root, diff --git a/pkgs/node/src/forkchoice.zig b/pkgs/node/src/forkchoice.zig index 621112143..a25144c91 100644 --- a/pkgs/node/src/forkchoice.zig +++ b/pkgs/node/src/forkchoice.zig @@ -1625,9 +1625,18 @@ pub const ForkChoice = struct { // we will use parent block later as per the finalization gadget _ = parent_block; - if (slot * constants.INTERVALS_PER_SLOT > self.fcStore.slot_clock.time.load(.monotonic)) { - return ForkChoiceError.FutureSlot; - } else if (slot < self.fcStore.latest_finalized.slot) { + // The future-slot gate (slot * INTERVALS_PER_SLOT > fcStore.time) + // used to live here but duplicated the gossip-level check in + // chain.validateBlock and disagreed with every other leanSpec + // consumer (ream, qlean-mini, ethlambda, nlean — none of which + // key block admission on the local clock). Rejecting here also + // caused chain.zig to keep a parallel pending-block queue just + // to replay these blocks on the next interval, which meant the + // block / its body attestations couldn't contribute to fork + // choice weight until the clock caught up. Now we mirror + // leanSpec store.on_block: if parent is known and the slot is + // not pre-finalized, just process it. + if (slot < self.fcStore.latest_finalized.slot) { return ForkChoiceError.PreFinalizedSlot; } @@ -1917,7 +1926,6 @@ pub const ForkChoice = struct { pub const ForkChoiceError = error{ NotImplemented, UnknownParent, - FutureSlot, InvalidFutureAttestation, InvalidOnChainAttestation, PreFinalizedSlot, @@ -1980,11 +1988,11 @@ test "forkchoice block tree" { const block = signed_block.block; try stf.apply_transition(allocator, &beam_state, block, .{ .logger = module_logger }); - // shouldn't accept a future slot - const current_slot = block.slot; - try std.testing.expectError(error.FutureSlot, fork_choice.onBlock(block, &beam_state, .{ .currentSlot = current_slot, .blockDelayMs = 0, .confirmed = true })); - - try fork_choice.onInterval(current_slot * constants.INTERVALS_PER_SLOT, false); + // forkchoice no longer gates block admission on the local clock — + // gossip-level future-slot rejection happens earlier in chain.validateBlock, + // matching leanSpec store.on_block semantics. Onblock only requires a + // known parent and a non-pre-finalized slot. + try fork_choice.onInterval(block.slot * constants.INTERVALS_PER_SLOT, false); _ = try fork_choice.onBlock(block, &beam_state, .{ .currentSlot = block.slot, .blockDelayMs = 0, .confirmed = true }); try std.testing.expect(fork_choice.protoArray.nodes.items.len == i + 1); try std.testing.expect(std.mem.eql(u8, &mock_chain.blockRoots[i], &fork_choice.protoArray.nodes.items[i].blockRoot)); diff --git a/pkgs/node/src/node.zig b/pkgs/node/src/node.zig index ed15a6362..e9fd89b8a 100644 --- a/pkgs/node/src/node.zig +++ b/pkgs/node/src/node.zig @@ -1139,19 +1139,6 @@ pub const BeamNode = struct { return e; }; - // Replay blocks that were queued waiting for the forkchoice clock to advance, - // then fetch any attestation head roots that were missing during replay. - const pending_missing_roots = self.chain.processPendingBlocks(); - defer self.allocator.free(pending_missing_roots); - if (pending_missing_roots.len > 0) { - self.fetchBlockByRoots(pending_missing_roots, 0) catch |err| { - self.logger.warn( - "failed to fetch {d} missing block(s) from pending blocks: {any}", - .{ pending_missing_roots.len, err }, - ); - }; - } - // Sweep timed-out RPC requests to prevent sync stalls from non-responsive peers. self.sweepTimedOutRequests(); From 5889fcfe98ad7aa59dca5683fca2f0b01cad7767 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 23 Apr 2026 21:24:40 +0800 Subject: [PATCH 6/9] fix(forkchoice): add missing leanSpec attestation checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leanSpec `store.py:validate_attestation` enforces two topology / consistency rules that zeam had been skipping: - `data.head.slot >= data.target.slot` — head checkpoint cannot be older than the target (topology check, store.py:304). - `head_block.slot == data.head.slot` — head checkpoint slot must match the actual block slot the checkpoint points at (consistency check, store.py:314). Without these, malformed gossip / aggregated attestations with a stale or mismatched head pointer slipped through zeam's validation while leanSpec and the rest of the ecosystem reject them. Add the two checks with matching `HeadOlderThanTarget` / `HeadCheckpointSlotMismatch` error variants and remove the stale `_ = head_block; // Will be used in future` placeholder now that the node is actually consulted. --- pkgs/node/src/chain.zig | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index c2dbbeb3e..0fc716817 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1400,7 +1400,6 @@ pub const BeamChain = struct { }); return AttestationValidationError.UnknownHeadBlock; }; - _ = head_block; // Will be used in future validations // 2. Validate slot relationships if (source_block.slot > target_block.slot) { @@ -1420,6 +1419,15 @@ pub const BeamChain = struct { return AttestationValidationError.SourceCheckpointExceedsTarget; } + // This corresponds to leanSpec's: assert data.head.slot >= data.target.slot + if (data.head.slot < data.target.slot) { + self.logger.debug("attestation validation failed: head slot {d} < target slot {d}", .{ + data.head.slot, + data.target.slot, + }); + return AttestationValidationError.HeadOlderThanTarget; + } + // 3. Validate checkpoint slots match block slots if (source_block.slot != data.source.slot) { self.logger.debug("attestation validation failed: source block slot {d} != source checkpoint slot {d}", .{ @@ -1438,6 +1446,15 @@ pub const BeamChain = struct { return AttestationValidationError.TargetCheckpointSlotMismatch; } + // This corresponds to leanSpec's: assert head_block.slot == attestation.head.slot + if (head_block.slot != data.head.slot) { + self.logger.debug("attestation validation failed: head block slot {d} != head checkpoint slot {d}", .{ + head_block.slot, + data.head.slot, + }); + return AttestationValidationError.HeadCheckpointSlotMismatch; + } + // 4. Validate attestation is not too far in the future // // Per leanSpec: allow current_slot + 1 for clock disparity tolerance, @@ -1734,6 +1751,8 @@ const AttestationValidationError = error{ SourceCheckpointExceedsTarget, SourceCheckpointSlotMismatch, TargetCheckpointSlotMismatch, + HeadCheckpointSlotMismatch, + HeadOlderThanTarget, AttestationTooFarInFuture, }; pub const BlockValidationError = error{ From a83d26b8a2e63940e81c8332469346d726f43ce0 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 23 Apr 2026 21:43:37 +0800 Subject: [PATCH 7/9] chore(chain): drop stale 'earlier revision' comment on aggregated path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block explained why re-validation was restored — context that is meaningful in a PR description but rots as a source comment: future readers have no "earlier revision" to compare against, and the rebase gating it references is documented at the rebase site itself. --- pkgs/node/src/chain.zig | 8 -------- 1 file changed, 8 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 0fc716817..b1fe8502a 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -1496,14 +1496,6 @@ pub const BeamChain = struct { } pub fn onGossipAggregatedAttestation(self: *Self, signedAggregation: types.SignedAggregatedAttestation) !void { - // Validate attestation data (same rules as individual gossip attestations). - // The earlier revision of this function skipped re-validation because the - // source block could vanish from proto-array when finalization advanced - // between publish and receipt. That race is now handled at the root — - // processFinalizationAdvancement gates the rebase call on - // PRUNE_NODE_THRESHOLD, keeping the source / target / head blocks - // addressable for the full grace window — so the stricter check is - // safe again. try self.validateAttestationData(signedAggregation.data, false); try self.verifyAggregatedAttestation(signedAggregation); From 76f31345d08be6135c1b8377fe19a7621a870a17 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 23 Apr 2026 22:38:56 +0800 Subject: [PATCH 8/9] fix(chain): address Copilot review on PR #754 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five follow-ups from the review pass: - Close RPC / sync future-slot gap: BeamChain.onBlock now enforces the MAX_FUTURE_SLOT_TOLERANCE (+1) bound itself. Previously only the gossip path funnelled through chain.validateBlock; RPC handlers went straight to onBlock and would happily run STF + fork-choice on blocks arbitrarily far in the future, a cheap DoS vector. - Make the PRUNE_NODE_THRESHOLD regression test actually exercise the gate by calling onBlockFollowup (where processFinalizationAdvancement — and the rebase call it gates — fires). Previously the test only called onBlock and would have passed even without the fix. - Update validateAttestationData doc and `attestation validation - gossip vs block future slot handling` test to describe the unified `current_slot + 1` rule both paths now follow; the test is renamed and now asserts both the accepted current+1 case and the rejected current+2 case on both is_from_block values. - Clarify PRUNE_NODE_THRESHOLD: the threshold is a proto-array node count, not a slot distance. Under heavy forking, node count advances faster than slot distance, so the wall-clock grace can shrink. Updated the constants.zig comment and the rebase call site accordingly. - Log loudly instead of silently skipping the rebase when latestFinalized.root is missing from proto-array. Under normal operation it is always present (getCanonicalViewAndAnalysis resolves it via protoArray.indices); surfacing the invariant violation beats hiding it behind "nothing happened". --- pkgs/node/src/chain.zig | 89 +++++++++++++++++++++++++++---------- pkgs/node/src/constants.zig | 19 ++++---- 2 files changed, 77 insertions(+), 31 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index b1fe8502a..6e09a9659 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -792,6 +792,19 @@ pub const BeamChain = struct { const block = signedBlock.block; + // Enforce the same MAX_FUTURE_SLOT_TOLERANCE bound that gossip's validateBlock + // applies, but at every onBlock entry — RPC/sync callers bypass validateBlock and + // would otherwise drive STF + fork-choice work for arbitrarily far-future blocks + // on behalf of a malicious peer. + const current_slot = self.forkChoice.fcStore.slot_clock.timeSlots.load(.monotonic); + if (block.slot > current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE) { + self.logger.debug("onBlock rejected: future slot {d} > max allowed {d}", .{ + block.slot, + current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE, + }); + return BlockValidationError.FutureSlot; + } + const block_root: types.Root = blockInfo.blockRoot orelse computedroot: { var cblock_root: [32]u8 = undefined; try zeam_utils.hashTreeRoot(types.BeamBlock, block, &cblock_root, self.allocator); @@ -1236,9 +1249,8 @@ pub const BeamChain = struct { // fail the existence checks in validateAttestationData with // Unknown{Source,Target,Head}Block, and the node burns bandwidth // re-fetching blocks that will never come back. The grace window - // must outlast the worst-case gossip delay plus at least one - // finalization tick; constants.PRUNE_NODE_THRESHOLD = 64 slots - // (≈256 s at SECONDS_PER_SLOT=4) is comfortably beyond both. + // is measured in proto-array node count — see + // constants.PRUNE_NODE_THRESHOLD for the sizing rationale. if (pruneForkchoice) { if (self.forkChoice.getProtoNodeIndex(latestFinalized.root)) |finalized_idx| { if (finalized_idx >= constants.PRUNE_NODE_THRESHOLD) { @@ -1246,6 +1258,16 @@ pub const BeamChain = struct { } // else: threshold not reached; keep pre-finalized ancestors // in proto-array so in-flight attestations still resolve. + } else { + // Shouldn't happen: getCanonicalViewAndAnalysis already resolved + // latestFinalized.root via protoArray.indices. If it ever does, + // proto-array has fallen out of sync with fcStore — log loudly so + // the invariant violation is visible, and keep skipping the rebase + // (which also drops pruning) rather than dereferencing a stale node. + self.logger.warn( + "forkchoice: finalized root 0x{x} missing from proto-array; skipping rebase (invariant violation)", + .{&latestFinalized.root}, + ); } } @@ -1368,11 +1390,12 @@ pub const BeamChain = struct { /// Validate incoming attestation before processing. /// - /// is_from_block: true if attestation came from a block, false if from network gossip + /// Per leanSpec `store.py:validate_attestation` (and `store.py:321`): a single + /// `data.slot <= current_slot + MAX_FUTURE_SLOT_TOLERANCE` bound applies to both + /// gossip and block attestations as a clock-disparity tolerance. /// - /// Per leanSpec: - /// - Gossip attestations (is_from_block=false): attestation.slot <= current_slot (no future tolerance) - /// - Block attestations (is_from_block=true): attestation.slot <= current_slot + 1 (lenient) + /// `is_from_block` is retained only to tag the debug log with the source; it no + /// longer changes the validation rule. pub fn validateAttestationData(self: *Self, data: types.AttestationData, is_from_block: bool) !void { const timer = zeam_metrics.lean_attestation_validation_time_seconds.start(); defer _ = timer.observe(); @@ -2292,10 +2315,11 @@ test "attestation validation - comprehensive" { // TODO: Enable and update this test once the keymanager file-reading PR is added // JSON parsing for chain config needs to support validator_attestation_pubkeys instead of num_validators -test "attestation validation - gossip vs block future slot handling" { - // Test that gossip and block attestations have different future slot tolerances - // Gossip: must be <= current_slot - // Block: can be <= current_slot + 1 +test "attestation validation - unified future slot tolerance" { + // leanSpec store.py:321 applies a single `data.slot <= current_slot + 1` bound to + // every attestation — gossip and block paths share the MAX_FUTURE_SLOT_TOLERANCE + // grace. Verify both that current+1 is accepted (the behavior the +1 tolerance is + // intended to protect) and that current+2 is rejected on both paths. var arena_allocator = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_allocator.deinit(); const allocator = arena_allocator.allocator(); @@ -2343,12 +2367,11 @@ test "attestation validation - gossip vs block future slot handling" { const missing_roots = try beam_chain.onBlock(block, .{}); allocator.free(missing_roots); - // Current time is at slot 1. Create attestation for slot 3 (current + 2), - // which exceeds the +1 tolerance for both gossip and block. + // Current time is at slot 1. current+1 (slot 2) is inside tolerance on both paths. const next_slot_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ - .slot = 3, + .slot = 2, .head = types.Checkpoint{ .root = mock_chain.blockRoots[1], .slot = 1, @@ -2364,14 +2387,31 @@ test "attestation validation - gossip vs block future slot handling" { }, .signature = ZERO_SIGBYTES, }; + try beam_chain.validateAttestationData(next_slot_attestation.message, false); + try beam_chain.validateAttestationData(next_slot_attestation.message, true); - // Gossip attestations: should FAIL for slot current + 2 - // Per spec store.py:177: assert attestation.slot <= time_slots - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(next_slot_attestation.message, false)); - - // Block attestations: should FAIL for slot current + 2 - // Per spec store.py:140: assert attestation.slot <= Slot(current_slot + Slot(1)) - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(next_slot_attestation.message, true)); + // current+2 (slot 3) exceeds the +1 tolerance on both paths. + const too_far_attestation: types.SignedAttestation = .{ + .validator_id = 0, + .message = .{ + .slot = 3, + .head = types.Checkpoint{ + .root = mock_chain.blockRoots[1], + .slot = 1, + }, + .source = types.Checkpoint{ + .root = mock_chain.blockRoots[0], + .slot = 0, + }, + .target = types.Checkpoint{ + .root = mock_chain.blockRoots[1], + .slot = 1, + }, + }, + .signature = ZERO_SIGBYTES, + }; + try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, false)); + try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, true)); } // TODO: Enable and update this test once the keymanager file-reading PR is added // JSON parsing for chain config needs to support validator_attestation_pubkeys instead of num_validators @@ -2663,14 +2703,17 @@ test "processFinalizationAdvancement: below PRUNE_NODE_THRESHOLD keeps pre-final ); defer beam_chain.deinit(); - // Drive the chain with default opts (pruneForkchoice = true) so the gate - // is actually exercised. + // Drive the chain through the same entrypoint node.zig uses so the gate + // (fired inside processFinalizationAdvancement → onBlockFollowup) is + // actually exercised. Calling onBlock alone never triggers the rebase + // path, so the test would pass even without the threshold fix. for (1..mock_chain.blocks.len) |i| { const signed_block = mock_chain.blocks[i]; const current_slot = signed_block.block.slot; try beam_chain.forkChoice.onInterval(current_slot * constants.INTERVALS_PER_SLOT, false); const missing_roots = try beam_chain.onBlock(signed_block, .{}); allocator.free(missing_roots); + beam_chain.onBlockFollowup(true, &signed_block); } // Sanity-check: mock chain must actually advance finalization for this diff --git a/pkgs/node/src/constants.zig b/pkgs/node/src/constants.zig index 90af5bc58..f2ed14919 100644 --- a/pkgs/node/src/constants.zig +++ b/pkgs/node/src/constants.zig @@ -21,7 +21,8 @@ pub const MAX_CACHED_BLOCKS = 1024; // Set to 7200 slots (approximately 8 hours in Lean, assuming 4 seconds per slot) pub const FORKCHOICE_PRUNING_INTERVAL_SLOTS: u64 = 7200; -// Grace window before proto-array rebuild fires on finalization advance. +// Grace window before proto-array rebuild fires on finalization advance, +// measured in *proto-array node count*, not slot distance. // // Eager rebase drops the just-finalized block's pre-finalized ancestors from // proto-array and remaps attestation-tracker indices. In-flight attestations @@ -30,13 +31,15 @@ pub const FORKCHOICE_PRUNING_INTERVAL_SLOTS: u64 = 7200; // though they were valid at sign time. 3SF-mini's fast finalization cadence // makes this race fire across normal gossip delay. // -// Gate rebase on the finalized node's position in proto-array: only rebuild -// once there are at least PRUNE_NODE_THRESHOLD pre-finalized nodes sitting -// before the finalized anchor. Below that, leave the prefix in place so -// in-flight attestations still resolve their references. 64 slots at -// SECONDS_PER_SLOT=4 gives ≈256 s of grace — several orders of magnitude -// wider than any realistic gossip rtt while costing only ~64 extra -// proto-nodes (bounded, small) of memory. +// Gate rebase on the finalized node's index inside proto-array: only rebuild +// once at least PRUNE_NODE_THRESHOLD pre-finalized nodes sit before the +// finalized anchor. Below that, leave the prefix in place so in-flight +// attestations still resolve their references. On the canonical chain 64 +// nodes corresponds to ~64 slots (≈256 s at SECONDS_PER_SLOT=4), but because +// the index counts every node in proto-array — including fork siblings — +// the wall-clock grace can be shorter under heavy forking. The bound on +// memory is directly in nodes (bounded, small) and is what this threshold +// is sizing for. pub const PRUNE_NODE_THRESHOLD: usize = 64; // Forkchoice visualization constants From 954cf400983d8b6c778d4f2482112f2bd9840260 Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Wed, 29 Apr 2026 11:47:53 +0800 Subject: [PATCH 9/9] forkchoice: align attestation time check with interval-grained leanSpec rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the gossip-attestation time check from slot-grained to interval-grained: data.slot * INTERVALS_PER_SLOT <= store.time + GOSSIP_DISPARITY_INTERVALS A whole-slot tolerance let an adversary pre-publish next-slot aggregates ahead of any honest validator (~800 ms head start at SECONDS_PER_SLOT=4 / INTERVALS_PER_SLOT=5). One interval bounds that head start to NTP drift. Block-included attestations skip the time check; they are trusted under the block's signature + STF validation. The block path itself drops the slot-grained future-slot bound entirely — block admission is gated by parent / signature / STF only. Removed: - constants.MAX_FUTURE_SLOT_TOLERANCE (was reused across attestation validation and validateBlock / onBlock; the block usages had no spec basis to begin with) - chain.validateBlock future-slot rejection - chain.onBlock entry-point future-slot rejection - chain.validateAttestationData block-path slot bound - BlockValidationError.FutureSlot variant - node.zig error.FutureSlot handlers and cacheFutureBlock helper (the cacheFutureBlock path had no replay mechanism after the time-based block queue was removed earlier in this PR) Added: - constants.GOSSIP_DISPARITY_INTERVALS = 1 - chain.validateAttestationData gossip-only interval bound - chain.zig boundary tests covering the gossip / block divergence --- pkgs/node/src/chain.zig | 159 +++++++++++++++++------------------ pkgs/node/src/constants.zig | 17 +++- pkgs/node/src/forkchoice.zig | 19 +---- pkgs/node/src/node.zig | 91 +------------------- 4 files changed, 97 insertions(+), 189 deletions(-) diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 6e09a9659..736434a26 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -465,13 +465,7 @@ pub const BeamChain = struct { } }; - // 4. Advance fork choice to this block's slot so the block is not rejected as FutureSlot - // PS: this isn't required because forkchoice is already ticked before validator's oninterval is called - // which then leads to block production call - // - // try self.forkChoice.onInterval(block.slot * constants.INTERVALS_PER_SLOT, false); - - // 5. Add the block to directly forkchoice as this proposer will next need to construct its vote + // 4. Add the block directly to forkchoice as this proposer will next need to construct its vote // note - attestations packed in the block are already in the knownVotes so we don't need to re-import // them in the forkchoice _ = try self.forkChoice.onBlock(block, post_state, .{ @@ -634,12 +628,8 @@ pub const BeamChain = struct { if (!hasBlock) { // Validation errors propagate to node.zig for context-aware logging. - // validateBlock enforces the MAX_FUTURE_SLOT_TOLERANCE (+1) bound - // consistent with leanSpec store.py:320; anything beyond the bound - // is rejected as BlockValidationError.FutureSlot and cached by - // node.zig for later retry. Blocks within the tolerance are - // processed immediately, aligning with leanSpec/ream/qlean-mini/ - // ethlambda/nlean — none of which queue blocks on the local clock. + // validateBlock checks parent / signature / STF preconditions; STF + // and signature verification are the gating layers for any block. try self.validateBlock(block, true); const missing_roots = self.onBlock(signed_block, .{ @@ -792,19 +782,6 @@ pub const BeamChain = struct { const block = signedBlock.block; - // Enforce the same MAX_FUTURE_SLOT_TOLERANCE bound that gossip's validateBlock - // applies, but at every onBlock entry — RPC/sync callers bypass validateBlock and - // would otherwise drive STF + fork-choice work for arbitrarily far-future blocks - // on behalf of a malicious peer. - const current_slot = self.forkChoice.fcStore.slot_clock.timeSlots.load(.monotonic); - if (block.slot > current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE) { - self.logger.debug("onBlock rejected: future slot {d} > max allowed {d}", .{ - block.slot, - current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE, - }); - return BlockValidationError.FutureSlot; - } - const block_root: types.Root = blockInfo.blockRoot orelse computedroot: { var cblock_root: [32]u8 = undefined; try zeam_utils.hashTreeRoot(types.BeamBlock, block, &cblock_root, self.allocator); @@ -1327,31 +1304,19 @@ pub const BeamChain = struct { /// flooding the node with invalid blocks. /// /// Validations performed: - /// 1. Future slot check: block.slot must not be too far in the future - /// 2. Pre-finalized slot check: block.slot must be >= finalized_slot - /// 3. Proposer index bounds check: proposer_index must be < validator_count - /// 4. Parent existence check: parent_root must be known - /// 5. Slot ordering check: block.slot must be > parent.slot + /// 1. Pre-finalized slot check: block.slot must be >= finalized_slot + /// 2. Proposer index bounds check: proposer_index must be < validator_count + /// 3. Parent existence check: parent_root must be known + /// 4. Slot ordering check: block.slot must be > parent.slot + /// + /// Block admission is gated by parent / signature / STF; the slot is only + /// checked relative to the finalized boundary and to the parent block. pub fn validateBlock(self: *Self, block: types.BeamBlock, is_from_gossip: bool) !void { _ = is_from_gossip; - const current_slot = self.forkChoice.fcStore.slot_clock.timeSlots.load(.monotonic); const finalized_slot = self.forkChoice.fcStore.latest_finalized.slot; - // 1. Future slot check - reject blocks too far in the future - // Allow a small tolerance for clock skew, but reject clearly invalid future slots - // this can also happen because of race conditions between oninterval and block arrival - const max_future_tolerance: types.Slot = constants.MAX_FUTURE_SLOT_TOLERANCE; - if (block.slot > current_slot + max_future_tolerance) { - self.logger.debug("block validation failed: future slot {d} > max allowed {d} time(intervals)={d}", .{ - block.slot, - current_slot + max_future_tolerance, - self.forkChoice.fcStore.slot_clock.time.load(.monotonic), - }); - return BlockValidationError.FutureSlot; - } - - // 2. Pre-finalized slot check - reject blocks before finalized slot + // 1. Pre-finalized slot check - reject blocks before finalized slot if (block.slot < finalized_slot) { self.logger.debug("block validation failed: pre-finalized slot {d} < finalized {d}", .{ block.slot, @@ -1360,7 +1325,7 @@ pub const BeamChain = struct { return BlockValidationError.PreFinalizedSlot; } - // 3. Proposer index bounds check - sanity check against registry limit + // 2. Proposer index bounds check - sanity check against registry limit // This is a fast pre-check; actual proposer validity is verified during signature verification // We use VALIDATOR_REGISTRY_LIMIT as the upper bound since the validator set can grow beyond genesis if (block.proposer_index >= params.VALIDATOR_REGISTRY_LIMIT) { @@ -1371,14 +1336,14 @@ pub const BeamChain = struct { return BlockValidationError.InvalidProposerIndex; } - // 4. Parent existence check + // 3. Parent existence check const parent_block = self.forkChoice.getBlock(block.parent_root); if (parent_block == null) { // Log decision moved to node.zig where we can check if parent is already being fetched return BlockValidationError.UnknownParentBlock; } - // 5. Slot ordering check - block slot must be greater than parent slot + // 4. Slot ordering check - block slot must be greater than parent slot if (block.slot <= parent_block.?.slot) { self.logger.debug("block validation failed: slot {d} <= parent slot {d}", .{ block.slot, @@ -1390,12 +1355,13 @@ pub const BeamChain = struct { /// Validate incoming attestation before processing. /// - /// Per leanSpec `store.py:validate_attestation` (and `store.py:321`): a single - /// `data.slot <= current_slot + MAX_FUTURE_SLOT_TOLERANCE` bound applies to both - /// gossip and block attestations as a clock-disparity tolerance. + /// The time check applies only to the gossip path: admit a vote iff + /// `data.slot * INTERVALS_PER_SLOT <= store.time + GOSSIP_DISPARITY_INTERVALS`. + /// 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. /// - /// `is_from_block` is retained only to tag the debug log with the source; it no - /// longer changes the validation rule. + /// Block-included attestations skip the time check; they are trusted under + /// the block's own validation. `is_from_block` is retained as a log marker. pub fn validateAttestationData(self: *Self, data: types.AttestationData, is_from_block: bool) !void { const timer = zeam_metrics.lean_attestation_validation_time_seconds.start(); defer _ = timer.observe(); @@ -1478,20 +1444,25 @@ pub const BeamChain = struct { return AttestationValidationError.HeadCheckpointSlotMismatch; } - // 4. Validate attestation is not too far in the future + // 4. Validate gossip attestation is not too far in the future. // - // Per leanSpec: allow current_slot + 1 for clock disparity tolerance, - // regardless of whether the attestation is from gossip or a block. - const current_slot = self.forkChoice.getCurrentSlot(); - const max_allowed_slot = current_slot + constants.MAX_FUTURE_SLOT_TOLERANCE; - - if (data.slot > max_allowed_slot) { - self.logger.debug("attestation validation failed: attestation slot {d} > max allowed slot {d} (is_from_block={any})", .{ - data.slot, - max_allowed_slot, - is_from_block, - }); - return AttestationValidationError.AttestationTooFarInFuture; + // Bound is in intervals, not slots, and only applies to the gossip + // path. Block-included attestations are trusted under the block's + // own validation (matching leanSpec on_block, which doesn't run + // validate_attestation on block-body attestations at all). + if (!is_from_block) { + const current_time = self.forkChoice.fcStore.slot_clock.time.load(.monotonic); + const attestation_start_interval = data.slot * constants.INTERVALS_PER_SLOT; + const max_allowed_interval = current_time + constants.GOSSIP_DISPARITY_INTERVALS; + if (attestation_start_interval > max_allowed_interval) { + self.logger.debug("attestation validation failed: gossip attestation start interval {d} > max allowed interval {d} (slot={d}, time={d})", .{ + attestation_start_interval, + max_allowed_interval, + data.slot, + current_time, + }); + return AttestationValidationError.AttestationTooFarInFuture; + } } self.logger.debug("attestation validation passed: slot={d} source={d} target={d} is_from_block={any}", .{ data.slot, @@ -1772,8 +1743,6 @@ const AttestationValidationError = error{ }; pub const BlockValidationError = error{ UnknownParentBlock, - /// Block slot is too far in the future - FutureSlot, /// Block slot is before the finalized slot PreFinalizedSlot, /// Block proposer_index exceeds validator count @@ -2288,12 +2257,17 @@ test "attestation validation - comprehensive" { try std.testing.expectError(error.TargetCheckpointSlotMismatch, beam_chain.validateAttestationData(invalid_attestation.message, false)); } - // Test 9: Attestation too far in future (for gossip) + // Test 9: Attestation too far in future (gossip path) + // + // Setup ended at time = 2 * INTERVALS_PER_SLOT = 10 (slot 2, interval 0). + // Gossip bound: data.slot * INTERVALS_PER_SLOT <= time + GOSSIP_DISPARITY_INTERVALS + // → max admitted slot here is ⌊(10 + 1) / 5⌋ = 2. + // slot 4 → start interval 20, well beyond 11. Rejected. { const future_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ - .slot = 4, // Two slots past current (current is 2, tolerance is +1) + .slot = 4, .head = types.Checkpoint{ .root = mock_chain.blockRoots[2], .slot = 2, @@ -2315,11 +2289,21 @@ test "attestation validation - comprehensive" { // TODO: Enable and update this test once the keymanager file-reading PR is added // JSON parsing for chain config needs to support validator_attestation_pubkeys instead of num_validators -test "attestation validation - unified future slot tolerance" { - // leanSpec store.py:321 applies a single `data.slot <= current_slot + 1` bound to - // every attestation — gossip and block paths share the MAX_FUTURE_SLOT_TOLERANCE - // grace. Verify both that current+1 is accepted (the behavior the +1 tolerance is - // intended to protect) and that current+2 is rejected on both paths. +test "attestation validation - gossip future-slot bound" { + // Gossip path is interval-grained: + // + // data.slot * INTERVALS_PER_SLOT <= time + GOSSIP_DISPARITY_INTERVALS + // + // Block-included attestations skip the time check entirely. + // + // Scenario: + // - Setup leaves the chain at slot 1, time = 5 (slot 1, interval 0). + // - A slot-2 vote at time = 5: gossip rejects (10 > 5 + 1 = 6). + // - Tick to time = 9 (slot 1, interval 4 — disparity boundary): + // gossip accepts (10 <= 9 + 1 = 10). + // - A slot-3 vote at time = 9: gossip rejects (15 > 9 + 1 = 10). + // - Block-included path admits the slot-3 vote at every tick. + var arena_allocator = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena_allocator.deinit(); const allocator = arena_allocator.allocator(); @@ -2361,13 +2345,12 @@ test "attestation validation - unified future slot tolerance" { var beam_chain = try BeamChain.init(allocator, ChainOpts{ .config = chain_config, .anchorState = &beam_state, .nodeId = 0, .logger_config = &zeam_logger_config, .db = db, .node_registry = test_registry }, connected_peers); defer beam_chain.deinit(); - // Add one block (slot 1) + // Add one block (slot 1). Forkchoice ticks to time = INTERVALS_PER_SLOT (slot 1, interval 0). const block = mock_chain.blocks[1]; try beam_chain.forkChoice.onInterval(block.block.slot * constants.INTERVALS_PER_SLOT, false); const missing_roots = try beam_chain.onBlock(block, .{}); allocator.free(missing_roots); - // Current time is at slot 1. current+1 (slot 2) is inside tolerance on both paths. const next_slot_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ @@ -2387,10 +2370,24 @@ test "attestation validation - unified future slot tolerance" { }, .signature = ZERO_SIGBYTES, }; - try beam_chain.validateAttestationData(next_slot_attestation.message, false); + + // At time = 5 the gossip path rejects a slot-2 vote (3 intervals shy of boundary). + try std.testing.expectError( + error.AttestationTooFarInFuture, + beam_chain.validateAttestationData(next_slot_attestation.message, false), + ); + + // Block-included attestations skip the time check. try beam_chain.validateAttestationData(next_slot_attestation.message, true); - // current+2 (slot 3) exceeds the +1 tolerance on both paths. + // Tick to the gossip disparity boundary: time = 2 * INTERVALS_PER_SLOT - GOSSIP_DISPARITY_INTERVALS = 9. + const boundary_time = 2 * constants.INTERVALS_PER_SLOT - constants.GOSSIP_DISPARITY_INTERVALS; + try beam_chain.forkChoice.onInterval(boundary_time, false); + + // At the boundary the gossip path admits the same vote. + try beam_chain.validateAttestationData(next_slot_attestation.message, false); + + // A slot-3 vote stays beyond the boundary on the gossip path but still admitted on the block path. const too_far_attestation: types.SignedAttestation = .{ .validator_id = 0, .message = .{ @@ -2411,7 +2408,7 @@ test "attestation validation - unified future slot tolerance" { .signature = ZERO_SIGBYTES, }; try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, false)); - try std.testing.expectError(error.AttestationTooFarInFuture, beam_chain.validateAttestationData(too_far_attestation.message, true)); + try beam_chain.validateAttestationData(too_far_attestation.message, true); } // TODO: Enable and update this test once the keymanager file-reading PR is added // JSON parsing for chain config needs to support validator_attestation_pubkeys instead of num_validators diff --git a/pkgs/node/src/constants.zig b/pkgs/node/src/constants.zig index f2ed14919..5916fc354 100644 --- a/pkgs/node/src/constants.zig +++ b/pkgs/node/src/constants.zig @@ -5,9 +5,20 @@ const params = @import("@zeam/params"); pub const INTERVALS_PER_SLOT = 5; pub const SECONDS_PER_INTERVAL_MS: isize = @divFloor(params.SECONDS_PER_SLOT * std.time.ms_per_s, INTERVALS_PER_SLOT); -// Maximum number of slots in the future that an attestation is allowed to reference -// This prevents accepting attestations that are too far ahead of the current slot -pub const MAX_FUTURE_SLOT_TOLERANCE = 1; +// Future-slot tolerance for gossip attestations, measured in intervals: +// +// data.slot * INTERVALS_PER_SLOT <= store.time + GOSSIP_DISPARITY_INTERVALS +// +// where store.time is in intervals. One interval is roughly 800 ms at +// SECONDS_PER_SLOT=4 / INTERVALS_PER_SLOT=5. +// +// A whole-slot tolerance would let an adversary pre-publish next-slot +// aggregates ahead of any honest validator (~800 ms head start at 4 s +// slots); tightening to one interval bounds that head start to NTP drift. +// +// Block-included attestations skip this check entirely; they are trusted +// under the block's own validation. +pub const GOSSIP_DISPARITY_INTERVALS = 1; // Maximum depth for recursive block fetching // When fetching parent blocks, we stop after this many levels to avoid infinite loops diff --git a/pkgs/node/src/forkchoice.zig b/pkgs/node/src/forkchoice.zig index a25144c91..89299526c 100644 --- a/pkgs/node/src/forkchoice.zig +++ b/pkgs/node/src/forkchoice.zig @@ -1625,17 +1625,9 @@ pub const ForkChoice = struct { // we will use parent block later as per the finalization gadget _ = parent_block; - // The future-slot gate (slot * INTERVALS_PER_SLOT > fcStore.time) - // used to live here but duplicated the gossip-level check in - // chain.validateBlock and disagreed with every other leanSpec - // consumer (ream, qlean-mini, ethlambda, nlean — none of which - // key block admission on the local clock). Rejecting here also - // caused chain.zig to keep a parallel pending-block queue just - // to replay these blocks on the next interval, which meant the - // block / its body attestations couldn't contribute to fork - // choice weight until the clock caught up. Now we mirror - // leanSpec store.on_block: if parent is known and the slot is - // not pre-finalized, just process it. + // Block admission only requires a known parent and a slot above + // the finalized boundary; STF and signature verification are the + // gating layers. if (slot < self.fcStore.latest_finalized.slot) { return ForkChoiceError.PreFinalizedSlot; } @@ -1988,10 +1980,7 @@ test "forkchoice block tree" { const block = signed_block.block; try stf.apply_transition(allocator, &beam_state, block, .{ .logger = module_logger }); - // forkchoice no longer gates block admission on the local clock — - // gossip-level future-slot rejection happens earlier in chain.validateBlock, - // matching leanSpec store.on_block semantics. Onblock only requires a - // known parent and a non-pre-finalized slot. + // onBlock only requires a known parent and a non-pre-finalized slot. try fork_choice.onInterval(block.slot * constants.INTERVALS_PER_SLOT, false); _ = try fork_choice.onBlock(block, &beam_state, .{ .currentSlot = block.slot, .blockDelayMs = 0, .confirmed = true }); try std.testing.expect(fork_choice.protoArray.nodes.items.len == i + 1); diff --git a/pkgs/node/src/node.zig b/pkgs/node/src/node.zig index b55c26acb..b186e6b94 100644 --- a/pkgs/node/src/node.zig +++ b/pkgs/node/src/node.zig @@ -264,35 +264,6 @@ pub const BeamNode = struct { } return; }, - // Block arrived too early for local clock - cache and retry later. - error.FutureSlot => { - if (data.* == .block) { - const signed_block = data.block; - var block_root: types.Root = undefined; - if (zeam_utils.hashTreeRoot(types.BeamBlock, signed_block.block, &block_root, self.allocator)) |_| { - if (self.cacheFutureBlock(block_root, signed_block)) |_| { - self.logger.debug( - "cached future gossip block 0x{s} at slot {d}", - .{ std.fmt.bytesToHex(block_root, .lower)[0..], signed_block.block.slot }, - ); - } else |cache_err| { - if (cache_err == CacheBlockError.PreFinalized) { - self.logger.info( - "future gossip block 0x{s} is pre-finalized (slot={d}), pruning cached descendants", - .{ std.fmt.bytesToHex(block_root, .lower)[0..], signed_block.block.slot }, - ); - _ = self.network.pruneCachedBlocks(block_root, null); - } else { - self.logger.warn("failed to cache future gossip block 0x{s}: {any}", .{ - std.fmt.bytesToHex(block_root, .lower)[0..], - cache_err, - }); - } - } - } else |_| {} - } - return; - }, // Attestation/aggregation validation failed due to missing head/source/target block - // downgrade to debug when the missing block is already being fetched. error.UnknownHeadBlock, error.UnknownSourceBlock, error.UnknownTargetBlock => { @@ -439,12 +410,6 @@ pub const BeamNode = struct { "Cached block 0x{x} still missing parent, keeping in cache", .{&descendant_root}, ); - } else if (err == error.FutureSlot) { - // Block is still in the future, keep it cached - self.logger.debug( - "Cached block 0x{s} still in future slot, keeping in cache", - .{std.fmt.bytesToHex(descendant_root, .lower)[0..]}, - ); } else if (err == forkchoice.ForkChoiceError.PreFinalizedSlot) { // This block is now before finalized (finalization advanced while it was cached). // Prune this block and all its cached descendants; they are no longer useful. @@ -594,60 +559,6 @@ pub const BeamNode = struct { return parent_root; } - fn cacheFutureBlock( - self: *Self, - block_root: types.Root, - signed_block: types.SignedBlock, - ) CacheBlockError!void { - const finalized_slot = self.chain.forkChoice.fcStore.latest_finalized.slot; - const block_slot = signed_block.block.slot; - - if (block_slot <= finalized_slot) { - return CacheBlockError.PreFinalized; - } - - if (self.network.hasFetchedBlock(block_root)) { - return CacheBlockError.AlreadyCached; - } - - if (self.network.fetched_blocks.count() >= constants.MAX_CACHED_BLOCKS) { - self.logger.warn("Cache full ({d} blocks), rejecting future block 0x{s} at slot {d}", .{ - self.network.fetched_blocks.count(), - std.fmt.bytesToHex(block_root, .lower)[0..], - block_slot, - }); - return CacheBlockError.CachingFailed; - } - - const block_ptr = self.allocator.create(types.SignedBlock) catch { - return CacheBlockError.AllocationFailed; - }; - var block_owned = true; - errdefer if (block_owned) self.allocator.destroy(block_ptr); - - // Clone the block and capture its SSZ bytes in one pass. - // sszCloneAndGetBytes serializes the original block once (read-only on `signed_block`), - // then deserializes into the clone. The returned bytes are stored alongside the cached - // block so that onBlock never needs to re-serialize a live SignedBlock, which has been - // observed to cause memory corruption on the next cached block's processing. - const ssz_bytes = types.sszCloneAndGetBytes(self.allocator, types.SignedBlock, signed_block, block_ptr) catch { - return CacheBlockError.CloneFailed; - }; - errdefer if (block_owned) block_ptr.deinit(); - errdefer self.allocator.free(ssz_bytes); - - self.network.cacheFetchedBlock(block_root, block_ptr) catch { - return CacheBlockError.CachingFailed; - }; - block_owned = false; - - // Store the SSZ bytes after caching; ignore store failure (block is already cached, - // onBlock will fall back to fresh serialization if bytes are unavailable). - self.network.storeFetchedBlockSsz(block_root, ssz_bytes) catch { - self.allocator.free(ssz_bytes); - }; - } - fn processBlockByRootChunk(self: *Self, block_ctx: *const BlockByRootContext, signed_block: *const types.SignedBlock) !void { var block_root: types.Root = undefined; if (zeam_utils.hashTreeRoot(types.BeamBlock, signed_block.block, &block_root, self.allocator)) |_| { @@ -1426,7 +1337,7 @@ pub const BeamNode = struct { pub fn run(self: *Self) !void { // Catch up fork choice time to current interval before processing any requests. - // This prevents FutureSlot errors when receiving blocks via RPC immediately after starting. + // Keeps validator duties and aggregation timing aligned with the local clock. const current_interval = self.clock.current_interval; if (current_interval > 0) { try self.chain.forkChoice.onInterval(@intCast(current_interval), false);