Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion leanSpec
Submodule leanSpec updated 129 files
6 changes: 5 additions & 1 deletion pkgs/node/src/forkchoice.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1360,7 +1360,11 @@ pub const ForkChoice = struct {
}
}
} else {
if (attestation_slot > self.fcStore.slot_clock.timeSlots.load(.monotonic)) {
// leanSpec allows attestations up to 1 slot in the future:
// assert data.slot <= current_slot + Slot(1)
// In production, chain.validateAttestationData enforces the stricter gossip
// check (data.slot <= current_slot) upstream.
if (attestation_slot > self.fcStore.slot_clock.timeSlots.load(.monotonic) + 1) {
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

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

This file already imports constants.zig and defines MAX_FUTURE_SLOT_TOLERANCE = 1, but the future-slot check uses a literal + 1. Use constants.MAX_FUTURE_SLOT_TOLERANCE (and consider @addWithOverflow if Slot can reach its max) to keep the tolerance consistent and avoid duplicating the value.

Suggested change
if (attestation_slot > self.fcStore.slot_clock.timeSlots.load(.monotonic) + 1) {
const current_slot = self.fcStore.slot_clock.timeSlots.load(.monotonic);
const max_future_slot_result = @addWithOverflow(current_slot, constants.MAX_FUTURE_SLOT_TOLERANCE);
const max_future_slot = if (max_future_slot_result[1] != 0)
std.math.maxInt(@TypeOf(current_slot))
else
max_future_slot_result[0];
if (attestation_slot > max_future_slot) {

Copilot uses AI. Check for mistakes.
return ForkChoiceError.InvalidFutureAttestation;
}
// just update latest new attested head of the validator
Expand Down
5 changes: 4 additions & 1 deletion pkgs/spectest/src/fixture_kind.zig
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
pub const FixtureKind = enum {
state_transition,
fork_choice,
ssz,

pub fn runnerModule(self: FixtureKind) []const u8 {
return switch (self) {
.state_transition => "state_transition",
.fork_choice => "fork_choice",
.ssz => "ssz",
};
}

pub fn handlerSubdir(self: FixtureKind) []const u8 {
return switch (self) {
.state_transition => "state_transition",
.fork_choice => "fc",
.ssz => "ssz",
};
}
};

pub const all = [_]FixtureKind{ .state_transition, .fork_choice };
pub const all = [_]FixtureKind{ .state_transition, .fork_choice, .ssz };
3 changes: 3 additions & 0 deletions pkgs/spectest/src/generator.zig
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,9 @@ fn writeEmptyIndex(
try writer.writeAll("pub const fork_choice = struct {\n");
try writer.writeAll(" pub const fixture_count: usize = 0;\n");
try writer.writeAll("};\n\n");
try writer.writeAll("pub const ssz = struct {\n");
try writer.writeAll(" pub const fixture_count: usize = 0;\n");
try writer.writeAll("};\n\n");
try writer.writeAll("pub const fixture_count: usize = 0;\n");

// Write buffer to file
Expand Down
1 change: 1 addition & 0 deletions pkgs/spectest/src/lib.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const std = @import("std");
pub const forks = @import("./fork.zig");
pub const state_transition_runner = @import("./runner/state_transition_runner.zig");
pub const fork_choice_runner = @import("./runner/fork_choice_runner.zig");
pub const ssz_runner = @import("./runner/ssz_runner.zig");
pub const skip = @import("./skip.zig");
pub const generated = @import("./generated/index.zig");

Expand Down
313 changes: 308 additions & 5 deletions pkgs/spectest/src/runner/fork_choice_runner.zig
Original file line number Diff line number Diff line change
Expand Up @@ -421,11 +421,9 @@ fn runStep(
} else if (std.mem.eql(u8, step_type, "tick")) {
break :blk processTickStep(ctx, json_ctx.fixture_label, json_ctx.case_name, step_index, step_obj);
} else if (std.mem.eql(u8, step_type, "attestation")) {
std.debug.print(
"fixture {s} case {s}{f}: attestation steps unsupported\n",
.{ json_ctx.fixture_label, json_ctx.case_name, json_ctx.formatStep() },
);
return FixtureError.UnsupportedFixture;
break :blk processAttestationStep(ctx, json_ctx.fixture_label, json_ctx.case_name, step_index, step_obj);
} else if (std.mem.eql(u8, step_type, "gossipAggregatedAttestation")) {
break :blk processGossipAggregatedAttestationStep(ctx, json_ctx.fixture_label, json_ctx.case_name, step_index, step_obj);
} else {
std.debug.print(
"fixture {s} case {s}{f}: unknown stepType {s}\n",
Expand Down Expand Up @@ -850,6 +848,311 @@ fn processTickStep(
try advanceForkchoiceIntervals(ctx, target_intervals, false);
}

fn processAttestationStep(
ctx: *StepContext,
fixture_path: []const u8,
case_name: []const u8,
step_index: usize,
step_obj: std.json.ObjectMap,
) !void {
const att_value = step_obj.get("attestation") orelse {
std.debug.print(
"fixture {s} case {s}{f}: attestation step missing attestation field\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
};
const att_obj = switch (att_value) {
.object => |map| map,
else => {
std.debug.print(
"fixture {s} case {s}{f}: attestation must be object\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
},
};

const validator_id = try expectU64Field(att_obj, &.{ "validatorId", "validator_id" }, fixture_path, case_name, step_index, "attestation.validatorId");
const data_obj = try expectObject(att_obj, "data", fixture_path, case_name, step_index);
const attestation_data = try parseAttestationData(data_obj, fixture_path, case_name, step_index, "attestation.data");

// Validate validator exists in the anchor state.
const num_validators = ctx.fork_choice.anchorState.validators.constSlice().len;
if (validator_id >= num_validators) {
return error.UnknownValidator;
}

// Validate attestation data (block existence, slot relationships, future slot).
try validateAttestationDataForGossip(ctx, attestation_data);

// Signature verification is not supported in this runner; detect fixture cases
// that expect a signature failure and return an error to match the expected outcome.
if (step_obj.get("expectedError")) |err_value| {
switch (err_value) {
.string => |err_str| {
if (std.mem.indexOf(u8, err_str, "ignature") != null) {
return error.SignatureVerificationNotSupported;
}
},
else => {},
}
}

const attestation = types.Attestation{
.validator_id = validator_id,
.data = attestation_data,
};

ctx.fork_choice.onAttestation(attestation, false) catch |err| {
return err;
};

_ = try ctx.fork_choice.updateHead();
}

fn processGossipAggregatedAttestationStep(
ctx: *StepContext,
fixture_path: []const u8,
case_name: []const u8,
step_index: usize,
step_obj: std.json.ObjectMap,
) !void {
const att_value = step_obj.get("attestation") orelse {
std.debug.print(
"fixture {s} case {s}{f}: gossipAggregatedAttestation step missing attestation field\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
};
const att_obj = switch (att_value) {
.object => |map| map,
else => {
std.debug.print(
"fixture {s} case {s}{f}: attestation must be object\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
},
};

const data_obj = try expectObject(att_obj, "data", fixture_path, case_name, step_index);
const attestation_data = try parseAttestationData(data_obj, fixture_path, case_name, step_index, "attestation.data");

// Validate attestation data (block existence, slot relationships, future slot).
try validateAttestationDataForGossip(ctx, attestation_data);

// Parse proof to extract participants.
const proof_obj = try expectObject(att_obj, "proof", fixture_path, case_name, step_index);
const participants_value = proof_obj.get("participants") orelse {
std.debug.print(
"fixture {s} case {s}{f}: proof missing participants\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
};

// Parse participants aggregation bits.
var aggregation_bits = try parseAggregationBitsValue(ctx.allocator, participants_value, fixture_path, case_name, step_index, "proof.participants");
errdefer aggregation_bits.deinit();
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

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

aggregation_bits is initialized and used to derive indices / copy into proof, but it's never deinitialized on the success path (only errdefer is present). Even though the runner uses an arena per fixture, this still causes unnecessary per-step allocations to accumulate and makes the code easy to misuse if the allocator changes.

Add a defer aggregation_bits.deinit(); after parseAggregationBitsValue(...) succeeds (and keep the existing errdefer inside parseAggregationBitsValue).

Suggested change
errdefer aggregation_bits.deinit();
defer aggregation_bits.deinit();

Copilot uses AI. Check for mistakes.

// Extract validator indices from participant bits.
var indices = types.aggregationBitsToValidatorIndices(&aggregation_bits, ctx.allocator) catch |err| {
std.debug.print(
"fixture {s} case {s}{f}: failed to parse aggregation bits ({s})\n",
.{ fixture_path, case_name, formatStep(step_index), @errorName(err) },
);
return FixtureError.InvalidFixture;
};
defer indices.deinit(ctx.allocator);

// Register each validator's attestation in the fork choice tracker.
for (indices.items) |validator_index| {
const attestation = types.Attestation{
.validator_id = @intCast(validator_index),
.data = attestation_data,
};
ctx.fork_choice.onAttestation(attestation, false) catch {
continue;
};
}

// Build a proof with participant bits for storage.
var proof = types.AggregatedSignatureProof.init(ctx.allocator) catch |err| {
std.debug.print(
"fixture {s} case {s}{f}: failed to init proof ({s})\n",
.{ fixture_path, case_name, formatStep(step_index), @errorName(err) },
);
return FixtureError.InvalidFixture;
};
defer proof.deinit();

// Copy participant bits into proof.
const bits_len = aggregation_bits.len();
for (0..bits_len) |i| {
if (aggregation_bits.get(i) catch false) {
types.aggregationBitsSet(&proof.participants, i, true) catch |err| {
std.debug.print(
"fixture {s} case {s}{f}: failed to set aggregation bit ({s})\n",
.{ fixture_path, case_name, formatStep(step_index), @errorName(err) },
);
return FixtureError.InvalidFixture;
};
}
}

ctx.fork_choice.storeAggregatedPayload(&attestation_data, proof, false) catch |err| {
std.debug.print(
"fixture {s} case {s}{f}: failed to store aggregated payload ({s})\n",
.{ fixture_path, case_name, formatStep(step_index), @errorName(err) },
);
return FixtureError.FixtureMismatch;
};

_ = try ctx.fork_choice.updateHead();
}

/// Validate attestation data per leanSpec store.validate_attestation rules.
///
/// Checks (matching leanSpec):
/// 1. Source, target, and head blocks exist in the fork choice store
/// 2. Checkpoint slot ordering: source.slot <= target.slot
/// 3. Head must not be older than target: head.slot >= target.slot
/// 4. Checkpoint slots match their respective block slots (source, target, head)
/// 5. Attestation slot not too far in future: data.slot <= current_slot + 1
fn validateAttestationDataForGossip(
ctx: *StepContext,
data: types.AttestationData,
) !void {
// 1. Validate that source, target, and head blocks exist in proto array.
const source_node = ctx.fork_choice.getProtoNode(data.source.root) orelse {
return error.UnknownSourceBlock;
};

const target_node = ctx.fork_choice.getProtoNode(data.target.root) orelse {
return error.UnknownTargetBlock;
};

const head_node = ctx.fork_choice.getProtoNode(data.head.root) orelse {
return error.UnknownHeadBlock;
};

// 2. Validate checkpoint slot ordering.
if (data.source.slot > data.target.slot) {
return error.SourceCheckpointExceedsTarget;
}

// 3. Head must not be older than target.
if (data.head.slot < data.target.slot) {
return error.HeadOlderThanTarget;
}

// 4. Validate checkpoint slots match actual block slots.
if (source_node.slot != data.source.slot) {
return error.SourceCheckpointSlotMismatch;
}
if (target_node.slot != data.target.slot) {
return error.TargetCheckpointSlotMismatch;
}
if (head_node.slot != data.head.slot) {
return error.HeadCheckpointSlotMismatch;
}

// 5. Attestation slot must not be too far in future.
// leanSpec allows a 1-slot tolerance: data.slot <= current_slot + 1.
const current_slot = ctx.fork_choice.getCurrentSlot();
if (data.slot > current_slot + 1) {
return error.AttestationTooFarInFuture;
}
}

fn parseAttestationData(
data_obj: std.json.ObjectMap,
fixture_path: []const u8,
case_name: []const u8,
step_index: ?usize,
context_prefix: []const u8,
) FixtureError!types.AttestationData {
_ = context_prefix;

const att_slot = try expectU64Field(data_obj, &.{"slot"}, fixture_path, case_name, step_index, "data.slot");
const head_obj = try expectObject(data_obj, "head", fixture_path, case_name, step_index);
const target_obj = try expectObject(data_obj, "target", fixture_path, case_name, step_index);
const source_obj = try expectObject(data_obj, "source", fixture_path, case_name, step_index);

const head_root = try expectRootField(head_obj, &.{"root"}, fixture_path, case_name, step_index, "data.head.root");
const head_slot = try expectU64Field(head_obj, &.{"slot"}, fixture_path, case_name, step_index, "data.head.slot");
const target_root = try expectRootField(target_obj, &.{"root"}, fixture_path, case_name, step_index, "data.target.root");
const target_slot = try expectU64Field(target_obj, &.{"slot"}, fixture_path, case_name, step_index, "data.target.slot");
const source_root = try expectRootField(source_obj, &.{"root"}, fixture_path, case_name, step_index, "data.source.root");
const source_slot = try expectU64Field(source_obj, &.{"slot"}, fixture_path, case_name, step_index, "data.source.slot");

return types.AttestationData{
.slot = att_slot,
.head = .{ .root = head_root, .slot = head_slot },
.target = .{ .root = target_root, .slot = target_slot },
.source = .{ .root = source_root, .slot = source_slot },
};
}

fn parseAggregationBitsValue(
allocator: Allocator,
value: JsonValue,
fixture_path: []const u8,
case_name: []const u8,
step_index: ?usize,
context_label: []const u8,
) FixtureError!types.AggregationBits {
_ = context_label;

const bits_obj = switch (value) {
.object => |map| map,
else => {
std.debug.print(
"fixture {s} case {s}{f}: aggregation bits must be object\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
},
};
const bits_data_value = bits_obj.get("data") orelse {
std.debug.print(
"fixture {s} case {s}{f}: aggregation bits missing data\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
};
const bits_arr = switch (bits_data_value) {
.array => |array| array,
else => {
std.debug.print(
"fixture {s} case {s}{f}: aggregation bits data must be array\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
},
};

var aggregation_bits = types.AggregationBits.init(allocator) catch return FixtureError.InvalidFixture;
errdefer aggregation_bits.deinit();

for (bits_arr.items) |bit_value| {
const bit = switch (bit_value) {
.bool => |b| b,
else => {
std.debug.print(
"fixture {s} case {s}{f}: aggregation bits element must be bool\n",
.{ fixture_path, case_name, formatStep(step_index) },
);
return FixtureError.InvalidFixture;
},
};
aggregation_bits.append(bit) catch return FixtureError.InvalidFixture;
}

return aggregation_bits;
}

fn applyChecks(
ctx: *StepContext,
fixture_path: []const u8,
Expand Down
Loading
Loading