From 60c7494511da0b555b2db1f05bf04eb6d5f72ebc Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 9 Apr 2026 22:52:40 +0800 Subject: [PATCH 1/3] feat(fork_choice): add Prometheus metrics module Add fork choice metrics aligned with Lodestar TypeScript implementation (packages/fork-choice/src/metrics.ts). Uses the same beacon_ prefix and metric names to ensure Grafana dashboard compatibility. Metrics include find_head histogram, reorg tracking, compute_deltas breakdown, and not_reorged_reason counters. --- src/fork_choice/metrics.zig | 189 ++++++++++++++++++++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 src/fork_choice/metrics.zig diff --git a/src/fork_choice/metrics.zig b/src/fork_choice/metrics.zig new file mode 100644 index 000000000..c14f8d473 --- /dev/null +++ b/src/fork_choice/metrics.zig @@ -0,0 +1,189 @@ +const std = @import("std"); +const Allocator = std.mem.Allocator; +const m = @import("metrics"); + +const fork_choice = @import("fork_choice.zig"); +const NotReorgedReason = fork_choice.NotReorgedReason; +const UpdateHeadOpt = fork_choice.UpdateHeadOpt; + +/// Defaults to noop metrics, making this safe to use whether or not `metrics.init` is called. +pub var fork_choice_metrics = m.initializeNoop(Metrics); + +const CallerLabel = struct { caller: UpdateHeadOpt }; +const EntrypointLabel = struct { entrypoint: UpdateHeadOpt }; +const ReasonLabel = struct { reason: NotReorgedReason }; + +const Metrics = struct { + find_head: FindHead, + requests: CountGauge, + errors: ErrorsGauge, + changed_head: CountGauge, + reorg: CountGauge, + reorg_distance: ReorgDistance, + votes: CountGauge, + queued_attestations: CountGauge, + validated_attestation_datas: CountGauge, + balances_length: CountGauge, + nodes: CountGauge, + indices: CountGauge, + not_reorged_reason: NotReorgedReasonCounter, + compute_deltas_duration: ComputeDeltasDuration, + compute_deltas_deltas_count: CountGauge, + compute_deltas_zero_deltas_count: CountGauge, + compute_deltas_equivocating_validators: CountGauge, + compute_deltas_old_inactive_validators: CountGauge, + compute_deltas_new_inactive_validators: CountGauge, + compute_deltas_unchanged_vote_validators: CountGauge, + compute_deltas_new_vote_validators: CountGauge, + + const FindHead = m.HistogramVec(f64, CallerLabel, &.{ 0.1, 1, 10 }); + const CountGauge = m.Gauge(u64); + const ErrorsGauge = m.GaugeVec(u64, EntrypointLabel); + const ReorgDistance = m.Histogram(u64, &.{ 1, 2, 3, 5, 7, 10, 20, 30, 50, 100 }); + const NotReorgedReasonCounter = m.CounterVec(u64, ReasonLabel); + const ComputeDeltasDuration = m.Histogram(f64, &.{ 0.01, 0.05, 0.1, 0.2 }); + + pub fn deinit(self: *Metrics) void { + self.find_head.deinit(); + self.errors.deinit(); + self.not_reorged_reason.deinit(); + } +}; + +/// Initializes all fork choice metrics. Requires an allocator and `io` for Vec metrics. +/// +/// Meant to be called once on application startup. +pub fn init(allocator: Allocator, io: std.Io, comptime opts: m.RegistryOpts) !void { + var find_head = try Metrics.FindHead.init( + allocator, + io, + "beacon_fork_choice_find_head_seconds", + .{ .help = "Time taken to find head in seconds" }, + opts, + ); + errdefer find_head.deinit(); + + var errors = try Metrics.ErrorsGauge.init( + allocator, + io, + "beacon_fork_choice_errors_total", + .{ .help = "Count of occasions where fork choice has returned an error when trying to find a head" }, + opts, + ); + errdefer errors.deinit(); + + var not_reorged_reason = try Metrics.NotReorgedReasonCounter.init( + allocator, + io, + "beacon_fork_choice_not_reorged_reason_total", + .{ .help = "Reason why the current head is not re-orged out" }, + opts, + ); + errdefer not_reorged_reason.deinit(); + + fork_choice_metrics = .{ + .find_head = find_head, + .requests = Metrics.CountGauge.init( + "beacon_fork_choice_requests_total", + .{ .help = "Count of occasions where fork choice has tried to find a head" }, + opts, + ), + .errors = errors, + .changed_head = Metrics.CountGauge.init( + "beacon_fork_choice_changed_head_total", + .{ .help = "Count of occasions fork choice has found a new head" }, + opts, + ), + .reorg = Metrics.CountGauge.init( + "beacon_fork_choice_reorg_total", + .{ .help = "Count of occasions fork choice has switched to a different chain" }, + opts, + ), + .reorg_distance = Metrics.ReorgDistance.init( + "beacon_fork_choice_reorg_distance", + .{ .help = "Histogram of re-org distance" }, + opts, + ), + .votes = Metrics.CountGauge.init( + "beacon_fork_choice_votes_count", + .{ .help = "Current count of votes in fork choice data structures" }, + opts, + ), + .queued_attestations = Metrics.CountGauge.init( + "beacon_fork_choice_queued_attestations_count", + .{ .help = "Count of queued_attestations in fork choice per slot" }, + opts, + ), + .validated_attestation_datas = Metrics.CountGauge.init( + "beacon_fork_choice_validated_attestation_datas_count", + .{ .help = "Current count of validatedAttestationDatas in fork choice data structures" }, + opts, + ), + .balances_length = Metrics.CountGauge.init( + "beacon_fork_choice_balances_length", + .{ .help = "Current length of balances in fork choice data structures" }, + opts, + ), + .nodes = Metrics.CountGauge.init( + "beacon_fork_choice_nodes_count", + .{ .help = "Current count of nodes in fork choice data structures" }, + opts, + ), + .indices = Metrics.CountGauge.init( + "beacon_fork_choice_indices_count", + .{ .help = "Current count of indices in fork choice data structures" }, + opts, + ), + .not_reorged_reason = not_reorged_reason, + .compute_deltas_duration = Metrics.ComputeDeltasDuration.init( + "beacon_fork_choice_compute_deltas_seconds", + .{ .help = "Time taken to compute deltas in seconds" }, + opts, + ), + .compute_deltas_deltas_count = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_deltas_count", + .{ .help = "Count of deltas computed" }, + opts, + ), + .compute_deltas_zero_deltas_count = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_zero_deltas_count", + .{ .help = "Count of zero deltas processed" }, + opts, + ), + .compute_deltas_equivocating_validators = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_equivocating_validators_count", + .{ .help = "Count of equivocating validators processed" }, + opts, + ), + .compute_deltas_old_inactive_validators = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_old_inactive_validators_count", + .{ .help = "Count of old inactive validators processed" }, + opts, + ), + .compute_deltas_new_inactive_validators = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_new_inactive_validators_count", + .{ .help = "Count of new inactive validators processed" }, + opts, + ), + .compute_deltas_unchanged_vote_validators = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_unchanged_vote_validators_count", + .{ .help = "Count of unchanged vote validators processed" }, + opts, + ), + .compute_deltas_new_vote_validators = Metrics.CountGauge.init( + "beacon_fork_choice_compute_deltas_new_vote_validators_count", + .{ .help = "Count of new vote validators processed" }, + opts, + ), + }; +} + +/// Writes all fork choice metrics to `writer`. +pub fn write(writer: anytype) !void { + try m.write(&fork_choice_metrics, writer); +} + +test "init compiles end-to-end" { + try init(std.testing.allocator, std.testing.io, .{}); + defer fork_choice_metrics.deinit(); +} From 287d9eeb3c8660f828e35d20ea6ce79811fc57cf Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Thu, 9 Apr 2026 22:56:46 +0800 Subject: [PATCH 2/3] feat(fork_choice): integrate metrics into fork choice module Wire up the metrics dependency (build.zig, zbuild.zon), export the metrics submodule from root.zig, and instrument computeDeltas in fork_choice.zig with timing and counter observations. --- bench/fork_choice/on_attestation.zig | 8 +- bench/fork_choice/update_head.zig | 28 +++---- bench/fork_choice/util.zig | 3 +- build.zig.zon | 2 +- src/fork_choice/fork_choice.zig | 108 ++++++++++++++++----------- src/fork_choice/root.zig | 1 + 6 files changed, 88 insertions(+), 62 deletions(-) diff --git a/bench/fork_choice/on_attestation.zig b/bench/fork_choice/on_attestation.zig index a2d45d1b5..b1c7015de 100644 --- a/bench/fork_choice/on_attestation.zig +++ b/bench/fork_choice/on_attestation.zig @@ -77,15 +77,15 @@ const OnAttestationBench = struct { /// 2. Compute head via updateAndGetHead. /// 3. Advance current_slot to 64 so attestations at slot 63 are past-slot. /// 4. Pre-build 405 unaggregated + 704 aggregated attestations with per-committee roots. -fn setupBench(allocator: std.mem.Allocator) !OnAttestationBench { - const fc = try util.initializeForkChoice(allocator, .{ +fn setupBench(allocator: std.mem.Allocator, io: std.Io) !OnAttestationBench { + const fc = try util.initializeForkChoice(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 0, }); // Compute head so fc.head is populated. - _ = fc.updateAndGetHead(allocator, .{ .get_canonical_head = {} }) catch unreachable; + _ = fc.updateAndGetHead(allocator, io, .{ .get_canonical_head = {} }) catch unreachable; // Advance store time so attestations at slot 63 are past-slot (immediate apply). fc.fc_store.current_slot = 64; @@ -188,7 +188,7 @@ pub fn main(init: std.process.Init) !void { var bench = zbench.Benchmark.init(allocator, .{}); - const b = try setupBench(allocator); + const b = try setupBench(allocator, io); defer deinitBench(allocator, b); try bench.addParam("onAttestation 1109 attestations (vc=600000 bc=64)", &b, .{}); diff --git a/bench/fork_choice/update_head.zig b/bench/fork_choice/update_head.zig index 88a1d5d42..1fff44683 100644 --- a/bench/fork_choice/update_head.zig +++ b/bench/fork_choice/update_head.zig @@ -22,6 +22,7 @@ const UpdateHeadBench = struct { vote1: u32, vote2: u32, flip: *bool, + io: std.Io, pub fn run(self: *UpdateHeadBench, allocator: std.mem.Allocator) void { const target = if (self.flip.*) self.vote2 else self.vote1; @@ -29,13 +30,13 @@ const UpdateHeadBench = struct { @memset(vote_fields.next_indices, target); self.flip.* = !self.flip.*; - _ = self.fc.updateAndGetHead(allocator, .{ .get_canonical_head = {} }) catch unreachable; + _ = self.fc.updateAndGetHead(allocator, self.io, .{ .get_canonical_head = {} }) catch unreachable; } }; /// Helper: set up one benchmark instance from the given parameters. -fn setupBench(allocator: std.mem.Allocator, opts: util.Opts) !UpdateHeadBench { - const fc = try util.initializeForkChoice(allocator, opts); +fn setupBench(allocator: std.mem.Allocator, io: std.Io, opts: util.Opts) !UpdateHeadBench { + const fc = try util.initializeForkChoice(allocator, io, opts); const vote1 = fc.proto_array.getDefaultNodeIndex(fc.head.block_root).?; const vote2 = fc.proto_array.getDefaultNodeIndex(fc.head.parent_root).?; @@ -46,7 +47,7 @@ fn setupBench(allocator: std.mem.Allocator, opts: util.Opts) !UpdateHeadBench { @memset(vote_fields.next_indices, vote1); // Run one initial updateHead so internal caches are primed. - _ = fc.updateAndGetHead(allocator, .{ .get_canonical_head = {} }) catch unreachable; + _ = fc.updateAndGetHead(allocator, io, .{ .get_canonical_head = {} }) catch unreachable; const flip = try allocator.create(bool); flip.* = true; @@ -56,6 +57,7 @@ fn setupBench(allocator: std.mem.Allocator, opts: util.Opts) !UpdateHeadBench { .vote1 = vote1, .vote2 = vote2, .flip = flip, + .io = io, }; } @@ -75,7 +77,7 @@ pub fn main(init: std.process.Init) !void { // ── Validator count sweep (block_count=64, equivocated=0) ── - const vc_100k = try setupBench(allocator, .{ + const vc_100k = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 100_000, .initial_equivocated_count = 0, @@ -83,7 +85,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, vc_100k); try bench.addParam("updateHead vc=100000 bc=64 eq=0", &vc_100k, .{}); - const vc_600k = try setupBench(allocator, .{ + const vc_600k = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 0, @@ -91,7 +93,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, vc_600k); try bench.addParam("updateHead vc=600000 bc=64 eq=0", &vc_600k, .{}); - const vc_1m = try setupBench(allocator, .{ + const vc_1m = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 1_000_000, .initial_equivocated_count = 0, @@ -101,7 +103,7 @@ pub fn main(init: std.process.Init) !void { // ── Block count sweep (validators=600_000, equivocated=0) ── - const bc_320 = try setupBench(allocator, .{ + const bc_320 = try setupBench(allocator, io, .{ .initial_block_count = 320, .initial_validator_count = 600_000, .initial_equivocated_count = 0, @@ -109,7 +111,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, bc_320); try bench.addParam("updateHead vc=600000 bc=320 eq=0", &bc_320, .{}); - const bc_1200 = try setupBench(allocator, .{ + const bc_1200 = try setupBench(allocator, io, .{ .initial_block_count = 1200, .initial_validator_count = 600_000, .initial_equivocated_count = 0, @@ -117,7 +119,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, bc_1200); try bench.addParam("updateHead vc=600000 bc=1200 eq=0", &bc_1200, .{}); - const bc_7200 = try setupBench(allocator, .{ + const bc_7200 = try setupBench(allocator, io, .{ .initial_block_count = 7200, .initial_validator_count = 600_000, .initial_equivocated_count = 0, @@ -127,7 +129,7 @@ pub fn main(init: std.process.Init) !void { // ── Equivocated count sweep (validators=600_000, blocks=64) ── - const eq_1k = try setupBench(allocator, .{ + const eq_1k = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 1_000, @@ -135,7 +137,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, eq_1k); try bench.addParam("updateHead vc=600000 bc=64 eq=1000", &eq_1k, .{}); - const eq_10k = try setupBench(allocator, .{ + const eq_10k = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 10_000, @@ -143,7 +145,7 @@ pub fn main(init: std.process.Init) !void { defer deinitBench(allocator, eq_10k); try bench.addParam("updateHead vc=600000 bc=64 eq=10000", &eq_10k, .{}); - const eq_300k = try setupBench(allocator, .{ + const eq_300k = try setupBench(allocator, io, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 300_000, diff --git a/bench/fork_choice/util.zig b/bench/fork_choice/util.zig index 921b7ad3d..18efe10e6 100644 --- a/bench/fork_choice/util.zig +++ b/bench/fork_choice/util.zig @@ -77,7 +77,7 @@ fn makeBlock(slot: u64, root: [32]u8, parent_root: [32]u8) ProtoBlock { /// /// Allocates a ProtoArray, ForkChoiceStore, and ForkChoice on the heap. /// The returned pointer must be freed via `deinitForkChoice`. -pub fn initializeForkChoice(allocator: Allocator, opts: Opts) !*ForkChoice { +pub fn initializeForkChoice(allocator: Allocator, io: std.Io, opts: Opts) !*ForkChoice { // -- Balances: every validator has effective balance = 32 ETH (increment = 32) -- const balances = try allocator.alloc(u16, opts.initial_validator_count); defer allocator.free(balances); @@ -117,6 +117,7 @@ pub fn initializeForkChoice(allocator: Allocator, opts: Opts) !*ForkChoice { errdefer allocator.destroy(fc); try fc.init( allocator, + io, &config.mainnet.config, fc_store, pa, diff --git a/build.zig.zon b/build.zig.zon index 5d354e5e8..29ace6b05 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -187,7 +187,7 @@ }, .fork_choice = .{ .root_source_file = "src/fork_choice/root.zig", - .imports = .{ .consensus_types, .config, .preset, .state_transition, .fork_types, .hex, .constants }, + .imports = .{ .consensus_types, .config, .preset, .state_transition, .fork_types, .hex, .constants, .metrics, .time }, }, }, .executables = .{ diff --git a/src/fork_choice/fork_choice.zig b/src/fork_choice/fork_choice.zig index 86cf26c4a..7c2d3ed1a 100644 --- a/src/fork_choice/fork_choice.zig +++ b/src/fork_choice/fork_choice.zig @@ -42,6 +42,9 @@ const compute_deltas = @import("compute_deltas.zig"); const computeDeltas = compute_deltas.computeDeltas; const DeltasCache = compute_deltas.DeltasCache; +const metrics = @import("metrics.zig"); +const time = @import("time"); + const store = @import("store.zig"); pub const ForkChoiceStore = store.ForkChoiceStore; pub const Checkpoint = store.Checkpoint; @@ -320,6 +323,7 @@ pub const ForkChoice = struct { pub fn init( self: *ForkChoice, allocator: Allocator, + io: std.Io, config: *const BeaconConfig, fc_store: *ForkChoiceStore, proto_array: *ProtoArray, @@ -348,7 +352,7 @@ pub const ForkChoice = struct { try self.votes.ensureValidatorCount(allocator, validator_count); // Compute initial head. - try self.updateHead(allocator); + try self.updateHead(allocator, io); } /// Release resources owned by ForkChoice (votes, caches, queued attestations). @@ -937,11 +941,13 @@ pub const ForkChoice = struct { /// /// Equivalent to: /// https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_head - fn updateHead(self: *ForkChoice, allocator: Allocator) !void { + fn updateHead(self: *ForkChoice, allocator: Allocator, io: std.Io) !void { // Check if scores need to be calculated/updated const old_balances = self.balances.get().items; const new_balances = self.fc_store.justified.balances.get().items; + const compute_deltas_timer = time.timestampNow(io); + const vote_fields = self.votes.fields(); const result = try computeDeltas( allocator, @@ -954,6 +960,21 @@ pub const ForkChoice = struct { &self.fc_store.equivocating_indices, ); + metrics.fork_choice_metrics.compute_deltas_duration.observe(time.durationSeconds(time.since(io, compute_deltas_timer))); + metrics.fork_choice_metrics.compute_deltas_deltas_count.set(@intCast(result.deltas.len)); + metrics.fork_choice_metrics.compute_deltas_equivocating_validators.set(result.equivocating_validators); + metrics.fork_choice_metrics.compute_deltas_old_inactive_validators.set(result.old_inactive_validators); + metrics.fork_choice_metrics.compute_deltas_new_inactive_validators.set(result.new_inactive_validators); + metrics.fork_choice_metrics.compute_deltas_unchanged_vote_validators.set(result.unchanged_vote_validators); + metrics.fork_choice_metrics.compute_deltas_new_vote_validators.set(result.new_vote_validators); + + // Count zero deltas. + var zero_count: u64 = 0; + for (result.deltas) |d| { + if (d == 0) zero_count += 1; + } + metrics.fork_choice_metrics.compute_deltas_zero_deltas_count.set(zero_count); + self.balances.unref(); self.balances = self.fc_store.justified.balances.ref(); @@ -1189,12 +1210,13 @@ pub const ForkChoice = struct { pub fn updateAndGetHead( self: *ForkChoice, allocator: Allocator, + io: std.Io, opt: UpdateAndGetHeadOpt, ) !UpdateAndGetHeadResult { const canonical_head: ProtoBlock = switch (opt) { .get_predicted_proposer_head => self.head, else => blk: { - try self.updateHead(allocator); + try self.updateHead(allocator, io); break :blk self.head; }, }; @@ -1417,13 +1439,13 @@ pub const ForkChoice = struct { /// /// Equivalent to: /// https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#on_tick - fn onTick(self: *ForkChoice, time: Slot) !void { + fn onTick(self: *ForkChoice, slot: Slot) !void { const previous_slot = self.fc_store.current_slot; - if (time > previous_slot + 1) return error.InconsistentOnTick; + if (slot > previous_slot + 1) return error.InconsistentOnTick; // Update store time. - self.fc_store.current_slot = time; + self.fc_store.current_slot = slot; // Reset proposer boost if this is a new slot. if (self.proposer_boost_root != null) { @@ -1431,7 +1453,7 @@ pub const ForkChoice = struct { } // Not a new epoch, return. - if (computeSlotsSinceEpochStart(time) != 0) { + if (computeSlotsSinceEpochStart(slot) != 0) { return; } @@ -2161,7 +2183,7 @@ fn initTestForkChoice( const fc = try allocator.create(ForkChoice); errdefer allocator.destroy(fc); - try fc.init(allocator, getTestConfig(), fc_store, proto_arr, 0, .{}); + try fc.init(allocator, std.testing.io, getTestConfig(), fc_store, proto_arr, 0, .{}); return fc; } @@ -2199,7 +2221,7 @@ fn initTestForkChoiceWithOpts( const fc = try allocator.create(ForkChoice); errdefer allocator.destroy(fc); - try fc.init(allocator, getTestConfig(), fc_store, proto_arr, 0, opts); + try fc.init(allocator, std.testing.io, getTestConfig(), fc_store, proto_arr, 0, opts); return fc; } @@ -2558,7 +2580,7 @@ test "getAllAncestorBlocks returns non-finalized ancestors from blockRoot" { // Vote for block_b to make it head. try fc.addLatestMessage(testing.allocator, 0, 2, block_b_root, .full); - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(block_b_root, fc.head.block_root); // Get ancestors starting from block_b — delegates to proto_array. @@ -2679,19 +2701,19 @@ test "onAttesterSlashing affects head via computeDeltas" { try fc.addLatestMessage(testing.allocator, 3, 3, c_root, .full); // Head should be b (weight: b=200+200=400 > c=300). - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(b_root, fc.head.block_root); // Slash validator 1 → b loses 200, now b=200 < c=300 → c becomes head. var slashing1 = makeTestAttesterSlashing(&[_]u64{1}); try fc.onAttesterSlashing(testing.allocator, &.{ .phase0 = &slashing1 }); - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(c_root, fc.head.block_root); // Re-slash validator 1 → noop (already slashed). c remains head. var slashing2 = makeTestAttesterSlashing(&[_]u64{1}); try fc.onAttesterSlashing(testing.allocator, &.{ .phase0 = &slashing2 }); - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(c_root, fc.head.block_root); } @@ -2731,7 +2753,7 @@ test "multiple forks competing with votes" { try fc.addLatestMessage(testing.allocator, 3, 2, d_root, .full); try fc.addLatestMessage(testing.allocator, 4, 2, d_root, .full); - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(d_root, fc.head.block_root); // Head chain: d → c → genesis. @@ -2784,7 +2806,7 @@ test "deep chain head selection follows longest weighted branch" { // Vote for the deepest block. const tip_root = hashFromByte(9); // slot 8, root 0x09 try fc.addLatestMessage(testing.allocator, 0, 8, tip_root, .full); - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); try testing.expectEqual(tip_root, fc.head.block_root); try testing.expectEqual(@as(usize, 9), fc.proto_array.nodes.items.len); // genesis + 8 blocks @@ -2895,7 +2917,7 @@ test "balance positive change: fresh votes with new balances" { try fc.addLatestMessage(allocator, 1, 2, root2, .full); try fc.addLatestMessage(allocator, 2, 3, root3, .full); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // Verify weights (back-propagated): node3=30, node2=20+30=50, node1=10+50=60. const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -2946,7 +2968,7 @@ test "balance negative change: existing balances decrease" { try fc.addLatestMessage(allocator, 2, 3, root3, .full); // First updateHead establishes votes with old_balances=100. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // Now update to lower balances. { @@ -2956,7 +2978,7 @@ test "balance negative change: existing balances decrease" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // After second updateHead, weights reflect the new lower balances. // node3: 30, node2: 20+30=50, node1: 10+50=60 (back-propagated). @@ -3003,7 +3025,7 @@ test "balance same slot change: balance update without vote movement" { try fc.addLatestMessage(allocator, 1, 2, root2, .full); // First updateHead with old_balances. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // Update balances without changing votes. { @@ -3013,7 +3035,7 @@ test "balance same slot change: balance update without vote movement" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // node2: 200, node1: 50+200=250 (back-propagated). const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -3064,7 +3086,7 @@ test "balance underflow clamping: old > new does not wrap unsigned" { try fc.addLatestMessage(allocator, 2, 3, root3, .full); // First updateHead with old_balances (125 each) establishes votes. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // Now update justified balances to new_balances (lower). Weight should decrease, not wrap. { @@ -3074,7 +3096,7 @@ test "balance underflow clamping: old > new does not wrap unsigned" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // All node weights should be non-negative (no underflow wrap). const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -3164,7 +3186,7 @@ test "IsCanonical: default head follows longest chain" { try onBlockFromProto(fc, testing.allocator, makeTestBlock(6, root6, root5), 10); // Default head should be 6 (longest/heaviest chain). - try fc.updateHead(testing.allocator); + try fc.updateHead(testing.allocator, std.testing.io); // Canonical chain: genesis → 2 → 4 → 5 → 6 try testing.expect(try fc.getCanonicalBlockByRoot(genesis_root) != null); // genesis: YES @@ -3329,7 +3351,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try onBlockFromProto(fc, allocator, makeTestBlock(3, g_root, d_root), 10); // Phase 1: No votes → head = highest leaf by root tiebreak. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // g_root=0x10 > f_root=0x0F > e_root=0x0E → deepest leaves are f,g,e. // Weight ties at 0 → best-child chosen by root. The head depends on tree // back-propagation: genesis has children a(0x0A) and b(0x0B). b > a by root, @@ -3340,7 +3362,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try fc.addLatestMessage(allocator, 0, 3, f_root, .full); try fc.addLatestMessage(allocator, 1, 3, f_root, .full); try fc.addLatestMessage(allocator, 2, 3, f_root, .full); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // a-branch weight: a=300(propagated), c=300, f=300. b-branch: b=0, e=0. // a-branch wins → head = f. try testing.expectEqual(f_root, fc.head.block_root); @@ -3350,7 +3372,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try fc.addLatestMessage(allocator, 4, 2, e_root, .full); try fc.addLatestMessage(allocator, 5, 2, e_root, .full); try fc.addLatestMessage(allocator, 6, 2, e_root, .full); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // a-branch=300, b-branch=400 → head = e. try testing.expectEqual(e_root, fc.head.block_root); @@ -3360,7 +3382,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { const epoch1_slot = preset.SLOTS_PER_EPOCH; try fc.addLatestMessage(allocator, 3, epoch1_slot, g_root, .full); try fc.addLatestMessage(allocator, 4, epoch1_slot, g_root, .full); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // a-branch: f=300, g=200, total through a = 500. // b-branch: e=200, total through b = 200. // a wins → head. Within a: c(300) vs d(200) → c wins → head = f. @@ -3369,7 +3391,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { // Phase 5: Slash V0 → f loses 100. f=200, g=200, e=200. var slashing = makeTestAttesterSlashing(&[_]u64{0}); try fc.onAttesterSlashing(allocator, &.{ .phase0 = &slashing }); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // a-branch: f=200, g=200, total through a = 400. // b-branch: e=200, total through b = 200. // a still wins. Within a: c(200) vs d(200) → root tiebreak d(0x0D) > c(0x0C) → head = g. @@ -3463,7 +3485,7 @@ test "Gloas head integration: EMPTY vs FULL tiebreaker via PTC" { // shouldExtendPayload: boost root = B, B's parent = A, B extends EMPTY → returns false. // EMPTY tiebreaker = 1, FULL tiebreaker = 0 (not timely, extends EMPTY) → EMPTY wins → head through B. fc.proposer_boost_root = b_root; - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(b_root, fc.head.block_root); // Head is B's EMPTY node. try testing.expectEqual(PayloadStatus.empty, fc.head.payload_status); @@ -3472,7 +3494,7 @@ test "Gloas head integration: EMPTY vs FULL tiebreaker via PTC" { // Set all PTC votes to true for block A. fc.proto_array.ptc_votes.getPtr(a_root).?.* = ProtoArray.PtcVotes.initFull(); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // FULL tiebreaker = 2, EMPTY tiebreaker = 1 → FULL wins → head follows C. try testing.expectEqual(c_root, fc.head.block_root); try testing.expectEqual(PayloadStatus.empty, fc.head.payload_status); @@ -3541,7 +3563,7 @@ test "head moves to valid branch after mass invalidation" { try fc.addLatestMessage(allocator, 4, 2, d_root, .full); try fc.addLatestMessage(allocator, 5, 2, d_root, .full); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(c_root, fc.head.block_root); // Phase 2: Invalidate A's branch. LVH = genesis exec hash. @@ -3554,7 +3576,7 @@ test "head moves to valid branch after mass invalidation" { }, 10); // Phase 3: updateHead → head should move to D (only viable branch). - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(d_root, fc.head.block_root); } @@ -3595,7 +3617,7 @@ test "Gloas forked branches attestation shift" { try fc.addLatestMessage(allocator, 0, 1, a_root, .pending); try fc.addLatestMessage(allocator, 1, 1, a_root, .pending); try fc.addLatestMessage(allocator, 2, 1, a_root, .pending); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(a_root, fc.head.block_root); // Phase 2: V3-V6 vote for B → head = B (4*100 > 3*100). @@ -3603,14 +3625,14 @@ test "Gloas forked branches attestation shift" { try fc.addLatestMessage(allocator, 4, 1, b_root, .pending); try fc.addLatestMessage(allocator, 5, 1, b_root, .pending); try fc.addLatestMessage(allocator, 6, 1, b_root, .pending); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(b_root, fc.head.block_root); // Phase 3: V3-V4 switch from B to A at epoch 1 (needs slot >= SLOTS_PER_EPOCH). const epoch1_slot = preset.SLOTS_PER_EPOCH; try fc.addLatestMessage(allocator, 3, epoch1_slot, a_root, .pending); try fc.addLatestMessage(allocator, 4, epoch1_slot, a_root, .pending); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // A now has 5 votes (V0,V1,V2,V3,V4), B has 2 (V5,V6). Head = A. try testing.expectEqual(a_root, fc.head.block_root); } @@ -3906,7 +3928,7 @@ test "onAttestation: valid attestation applies vote (past slot)" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xA1), false); // Validator 0 should now have a vote. Head should be block_root. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(block_root, fc.head.block_root); } @@ -3997,7 +4019,7 @@ test "onAttestation: votes shift head between forks" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xC1), false); } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(a_root, fc.head.block_root); // V3-V5 vote for B → B has 3 votes, A has 3 → tiebreaker. Let's add more so B wins. @@ -4010,7 +4032,7 @@ test "onAttestation: votes shift head between forks" { // Tie: 3*100 vs 3*100. Winner decided by root comparison (higher root wins in tiebreaker). // Regardless of tiebreak, let's verify votes were applied by checking both branches have weight. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // The tiebreaker selects based on root bytes — just verify head is one of them. try testing.expect(std.mem.eql(u8, &fc.head.block_root, &a_root) or std.mem.eql(u8, &fc.head.block_root, &b_root)); } @@ -4046,7 +4068,7 @@ test "onAttestation: epoch advancement allows vote update" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xD1), false); } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(a_root, fc.head.block_root); // Validator 0 switches vote to B in epoch 1 (epoch advances). @@ -4058,7 +4080,7 @@ test "onAttestation: epoch advancement allows vote update" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xD2), false); } - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(b_root, fc.head.block_root); } @@ -4102,7 +4124,7 @@ test "onAttestation: proposer boost outweighs attestation votes" { // Head: A has 128 (1 vote). B has proposer_boost (51). // A should win with 128 > 51. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(a_root, fc.head.block_root); } @@ -4150,7 +4172,7 @@ test "onAttestation: equivocating validator votes are not counted" { } // Head should be B since validator 0's vote was excluded. - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); try testing.expectEqual(b_root, fc.head.block_root); } @@ -4294,7 +4316,7 @@ test "getCanonicalBlockByRoot finds ancestor on canonical chain" { try onBlockFromProto(fc, allocator, makeTestBlock(1, a_root, genesis_root), 10); try onBlockFromProto(fc, allocator, makeTestBlock(2, b_root, a_root), 10); - try fc.updateHead(allocator); + try fc.updateHead(allocator, std.testing.io); // Head should be B (longest chain). try testing.expectEqual(b_root, fc.head.block_root); diff --git a/src/fork_choice/root.zig b/src/fork_choice/root.zig index e3223f472..b817f323d 100644 --- a/src/fork_choice/root.zig +++ b/src/fork_choice/root.zig @@ -6,6 +6,7 @@ pub const compute_deltas = @import("compute_deltas.zig"); pub const proto_array = @import("proto_array.zig"); pub const store = @import("store.zig"); pub const fork_choice = @import("fork_choice.zig"); +pub const metrics = @import("metrics.zig"); pub const ProtoBlock = proto_array.ProtoBlock; pub const ProtoNode = proto_array.ProtoNode; From bfeb3a1e62523d353e0379bfd45c1ca1a119d44e Mon Sep 17 00:00:00 2001 From: Chen Kai <281165273grape@gmail.com> Date: Tue, 12 May 2026 14:13:40 +0800 Subject: [PATCH 3/3] refactor(fork_choice): move metric observation to updateAndGetHead Pull the `computeDeltas` metric block out of the private `updateHead` and into its only non-init caller `updateAndGetHead`, mirroring the TS layering where metric observation lives one level above the compute step. As a consequence `updateHead` no longer needs `io: std.Io` and returns the `ComputeDeltasResult` so the caller can read counter fields. `init` also drops `io`: the startup head computation is one-shot and not metric-observed, matching how TS skips the constructor call from `addCollect` tracking. Addresses review feedback (PR #309): `updateHead`'s `io` parameter was an overweight surface area for what amounted to two `timestampNow` calls. --- bench/fork_choice/on_attestation.zig | 2 +- bench/fork_choice/update_head.zig | 2 +- bench/fork_choice/util.zig | 3 +- src/fork_choice/fork_choice.zig | 122 ++++++++++++++------------- 4 files changed, 67 insertions(+), 62 deletions(-) diff --git a/bench/fork_choice/on_attestation.zig b/bench/fork_choice/on_attestation.zig index b1c7015de..04ad42228 100644 --- a/bench/fork_choice/on_attestation.zig +++ b/bench/fork_choice/on_attestation.zig @@ -78,7 +78,7 @@ const OnAttestationBench = struct { /// 3. Advance current_slot to 64 so attestations at slot 63 are past-slot. /// 4. Pre-build 405 unaggregated + 704 aggregated attestations with per-committee roots. fn setupBench(allocator: std.mem.Allocator, io: std.Io) !OnAttestationBench { - const fc = try util.initializeForkChoice(allocator, io, .{ + const fc = try util.initializeForkChoice(allocator, .{ .initial_block_count = 64, .initial_validator_count = 600_000, .initial_equivocated_count = 0, diff --git a/bench/fork_choice/update_head.zig b/bench/fork_choice/update_head.zig index 1fff44683..ad08d499f 100644 --- a/bench/fork_choice/update_head.zig +++ b/bench/fork_choice/update_head.zig @@ -36,7 +36,7 @@ const UpdateHeadBench = struct { /// Helper: set up one benchmark instance from the given parameters. fn setupBench(allocator: std.mem.Allocator, io: std.Io, opts: util.Opts) !UpdateHeadBench { - const fc = try util.initializeForkChoice(allocator, io, opts); + const fc = try util.initializeForkChoice(allocator, opts); const vote1 = fc.proto_array.getDefaultNodeIndex(fc.head.block_root).?; const vote2 = fc.proto_array.getDefaultNodeIndex(fc.head.parent_root).?; diff --git a/bench/fork_choice/util.zig b/bench/fork_choice/util.zig index 18efe10e6..921b7ad3d 100644 --- a/bench/fork_choice/util.zig +++ b/bench/fork_choice/util.zig @@ -77,7 +77,7 @@ fn makeBlock(slot: u64, root: [32]u8, parent_root: [32]u8) ProtoBlock { /// /// Allocates a ProtoArray, ForkChoiceStore, and ForkChoice on the heap. /// The returned pointer must be freed via `deinitForkChoice`. -pub fn initializeForkChoice(allocator: Allocator, io: std.Io, opts: Opts) !*ForkChoice { +pub fn initializeForkChoice(allocator: Allocator, opts: Opts) !*ForkChoice { // -- Balances: every validator has effective balance = 32 ETH (increment = 32) -- const balances = try allocator.alloc(u16, opts.initial_validator_count); defer allocator.free(balances); @@ -117,7 +117,6 @@ pub fn initializeForkChoice(allocator: Allocator, io: std.Io, opts: Opts) !*Fork errdefer allocator.destroy(fc); try fc.init( allocator, - io, &config.mainnet.config, fc_store, pa, diff --git a/src/fork_choice/fork_choice.zig b/src/fork_choice/fork_choice.zig index 7c2d3ed1a..46f91aec0 100644 --- a/src/fork_choice/fork_choice.zig +++ b/src/fork_choice/fork_choice.zig @@ -40,6 +40,7 @@ const INIT_VOTE_SLOT = vote_tracker.INIT_VOTE_SLOT; const compute_deltas = @import("compute_deltas.zig"); const computeDeltas = compute_deltas.computeDeltas; +const ComputeDeltasResult = compute_deltas.ComputeDeltasResult; const DeltasCache = compute_deltas.DeltasCache; const metrics = @import("metrics.zig"); @@ -323,7 +324,6 @@ pub const ForkChoice = struct { pub fn init( self: *ForkChoice, allocator: Allocator, - io: std.Io, config: *const BeaconConfig, fc_store: *ForkChoiceStore, proto_array: *ProtoArray, @@ -351,8 +351,9 @@ pub const ForkChoice = struct { // Pre-allocate votes for known validators, initialized to NULL_VOTE_INDEX. try self.votes.ensureValidatorCount(allocator, validator_count); - // Compute initial head. - try self.updateHead(allocator, io); + // Compute initial head. The startup `computeDeltas` call is one-shot and + // not metric-observed; observations happen in `updateAndGetHead`. + _ = try self.updateHead(allocator); } /// Release resources owned by ForkChoice (votes, caches, queued attestations). @@ -941,13 +942,11 @@ pub const ForkChoice = struct { /// /// Equivalent to: /// https://github.com/ethereum/consensus-specs/blob/v1.1.10/specs/phase0/fork-choice.md#get_head - fn updateHead(self: *ForkChoice, allocator: Allocator, io: std.Io) !void { + fn updateHead(self: *ForkChoice, allocator: Allocator) !ComputeDeltasResult { // Check if scores need to be calculated/updated const old_balances = self.balances.get().items; const new_balances = self.fc_store.justified.balances.get().items; - const compute_deltas_timer = time.timestampNow(io); - const vote_fields = self.votes.fields(); const result = try computeDeltas( allocator, @@ -960,21 +959,6 @@ pub const ForkChoice = struct { &self.fc_store.equivocating_indices, ); - metrics.fork_choice_metrics.compute_deltas_duration.observe(time.durationSeconds(time.since(io, compute_deltas_timer))); - metrics.fork_choice_metrics.compute_deltas_deltas_count.set(@intCast(result.deltas.len)); - metrics.fork_choice_metrics.compute_deltas_equivocating_validators.set(result.equivocating_validators); - metrics.fork_choice_metrics.compute_deltas_old_inactive_validators.set(result.old_inactive_validators); - metrics.fork_choice_metrics.compute_deltas_new_inactive_validators.set(result.new_inactive_validators); - metrics.fork_choice_metrics.compute_deltas_unchanged_vote_validators.set(result.unchanged_vote_validators); - metrics.fork_choice_metrics.compute_deltas_new_vote_validators.set(result.new_vote_validators); - - // Count zero deltas. - var zero_count: u64 = 0; - for (result.deltas) |d| { - if (d == 0) zero_count += 1; - } - metrics.fork_choice_metrics.compute_deltas_zero_deltas_count.set(zero_count); - self.balances.unref(); self.balances = self.fc_store.justified.balances.ref(); @@ -1007,6 +991,7 @@ pub const ForkChoice = struct { ); self.head = head.toBlock(); + return result; } /// Get the cached head (without recomputing). @@ -1216,7 +1201,9 @@ pub const ForkChoice = struct { const canonical_head: ProtoBlock = switch (opt) { .get_predicted_proposer_head => self.head, else => blk: { - try self.updateHead(allocator, io); + const compute_deltas_timer = time.timestampNow(io); + const result = try self.updateHead(allocator); + observeComputeDeltasMetrics(io, compute_deltas_timer, result); break :blk self.head; }, }; @@ -1230,6 +1217,25 @@ pub const ForkChoice = struct { }; } + /// Record `computeDeltas` timing and counter metrics. Pulled out of + /// `updateAndGetHead` so the public hot path stays readable. + fn observeComputeDeltasMetrics(io: std.Io, start: std.Io.Timestamp, result: ComputeDeltasResult) void { + const fm = &metrics.fork_choice_metrics; + fm.compute_deltas_duration.observe(time.durationSeconds(time.since(io, start))); + fm.compute_deltas_deltas_count.set(@intCast(result.deltas.len)); + fm.compute_deltas_equivocating_validators.set(result.equivocating_validators); + fm.compute_deltas_old_inactive_validators.set(result.old_inactive_validators); + fm.compute_deltas_new_inactive_validators.set(result.new_inactive_validators); + fm.compute_deltas_unchanged_vote_validators.set(result.unchanged_vote_validators); + fm.compute_deltas_new_vote_validators.set(result.new_vote_validators); + + var zero_count: u64 = 0; + for (result.deltas) |d| if (d == 0) { + zero_count += 1; + }; + fm.compute_deltas_zero_deltas_count.set(zero_count); + } + // ── Equivocation ── /// Mark validators as equivocating (attester slashing). @@ -2183,7 +2189,7 @@ fn initTestForkChoice( const fc = try allocator.create(ForkChoice); errdefer allocator.destroy(fc); - try fc.init(allocator, std.testing.io, getTestConfig(), fc_store, proto_arr, 0, .{}); + try fc.init(allocator, getTestConfig(), fc_store, proto_arr, 0, .{}); return fc; } @@ -2221,7 +2227,7 @@ fn initTestForkChoiceWithOpts( const fc = try allocator.create(ForkChoice); errdefer allocator.destroy(fc); - try fc.init(allocator, std.testing.io, getTestConfig(), fc_store, proto_arr, 0, opts); + try fc.init(allocator, getTestConfig(), fc_store, proto_arr, 0, opts); return fc; } @@ -2580,7 +2586,7 @@ test "getAllAncestorBlocks returns non-finalized ancestors from blockRoot" { // Vote for block_b to make it head. try fc.addLatestMessage(testing.allocator, 0, 2, block_b_root, .full); - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(block_b_root, fc.head.block_root); // Get ancestors starting from block_b — delegates to proto_array. @@ -2701,19 +2707,19 @@ test "onAttesterSlashing affects head via computeDeltas" { try fc.addLatestMessage(testing.allocator, 3, 3, c_root, .full); // Head should be b (weight: b=200+200=400 > c=300). - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(b_root, fc.head.block_root); // Slash validator 1 → b loses 200, now b=200 < c=300 → c becomes head. var slashing1 = makeTestAttesterSlashing(&[_]u64{1}); try fc.onAttesterSlashing(testing.allocator, &.{ .phase0 = &slashing1 }); - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(c_root, fc.head.block_root); // Re-slash validator 1 → noop (already slashed). c remains head. var slashing2 = makeTestAttesterSlashing(&[_]u64{1}); try fc.onAttesterSlashing(testing.allocator, &.{ .phase0 = &slashing2 }); - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(c_root, fc.head.block_root); } @@ -2753,7 +2759,7 @@ test "multiple forks competing with votes" { try fc.addLatestMessage(testing.allocator, 3, 2, d_root, .full); try fc.addLatestMessage(testing.allocator, 4, 2, d_root, .full); - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(d_root, fc.head.block_root); // Head chain: d → c → genesis. @@ -2806,7 +2812,7 @@ test "deep chain head selection follows longest weighted branch" { // Vote for the deepest block. const tip_root = hashFromByte(9); // slot 8, root 0x09 try fc.addLatestMessage(testing.allocator, 0, 8, tip_root, .full); - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); try testing.expectEqual(tip_root, fc.head.block_root); try testing.expectEqual(@as(usize, 9), fc.proto_array.nodes.items.len); // genesis + 8 blocks @@ -2917,7 +2923,7 @@ test "balance positive change: fresh votes with new balances" { try fc.addLatestMessage(allocator, 1, 2, root2, .full); try fc.addLatestMessage(allocator, 2, 3, root3, .full); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // Verify weights (back-propagated): node3=30, node2=20+30=50, node1=10+50=60. const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -2968,7 +2974,7 @@ test "balance negative change: existing balances decrease" { try fc.addLatestMessage(allocator, 2, 3, root3, .full); // First updateHead establishes votes with old_balances=100. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // Now update to lower balances. { @@ -2978,7 +2984,7 @@ test "balance negative change: existing balances decrease" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // After second updateHead, weights reflect the new lower balances. // node3: 30, node2: 20+30=50, node1: 10+50=60 (back-propagated). @@ -3025,7 +3031,7 @@ test "balance same slot change: balance update without vote movement" { try fc.addLatestMessage(allocator, 1, 2, root2, .full); // First updateHead with old_balances. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // Update balances without changing votes. { @@ -3035,7 +3041,7 @@ test "balance same slot change: balance update without vote movement" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // node2: 200, node1: 50+200=250 (back-propagated). const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -3086,7 +3092,7 @@ test "balance underflow clamping: old > new does not wrap unsigned" { try fc.addLatestMessage(allocator, 2, 3, root3, .full); // First updateHead with old_balances (125 each) establishes votes. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // Now update justified balances to new_balances (lower). Weight should decrease, not wrap. { @@ -3096,7 +3102,7 @@ test "balance underflow clamping: old > new does not wrap unsigned" { fc.fc_store.justified.balances.unref(); fc.fc_store.justified.balances = new_rc; } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // All node weights should be non-negative (no underflow wrap). const idx1 = fc.proto_array.getDefaultNodeIndex(root1) orelse return error.TestUnexpectedResult; @@ -3186,7 +3192,7 @@ test "IsCanonical: default head follows longest chain" { try onBlockFromProto(fc, testing.allocator, makeTestBlock(6, root6, root5), 10); // Default head should be 6 (longest/heaviest chain). - try fc.updateHead(testing.allocator, std.testing.io); + _ = try fc.updateHead(testing.allocator); // Canonical chain: genesis → 2 → 4 → 5 → 6 try testing.expect(try fc.getCanonicalBlockByRoot(genesis_root) != null); // genesis: YES @@ -3351,7 +3357,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try onBlockFromProto(fc, allocator, makeTestBlock(3, g_root, d_root), 10); // Phase 1: No votes → head = highest leaf by root tiebreak. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // g_root=0x10 > f_root=0x0F > e_root=0x0E → deepest leaves are f,g,e. // Weight ties at 0 → best-child chosen by root. The head depends on tree // back-propagation: genesis has children a(0x0A) and b(0x0B). b > a by root, @@ -3362,7 +3368,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try fc.addLatestMessage(allocator, 0, 3, f_root, .full); try fc.addLatestMessage(allocator, 1, 3, f_root, .full); try fc.addLatestMessage(allocator, 2, 3, f_root, .full); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // a-branch weight: a=300(propagated), c=300, f=300. b-branch: b=0, e=0. // a-branch wins → head = f. try testing.expectEqual(f_root, fc.head.block_root); @@ -3372,7 +3378,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { try fc.addLatestMessage(allocator, 4, 2, e_root, .full); try fc.addLatestMessage(allocator, 5, 2, e_root, .full); try fc.addLatestMessage(allocator, 6, 2, e_root, .full); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // a-branch=300, b-branch=400 → head = e. try testing.expectEqual(e_root, fc.head.block_root); @@ -3382,7 +3388,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { const epoch1_slot = preset.SLOTS_PER_EPOCH; try fc.addLatestMessage(allocator, 3, epoch1_slot, g_root, .full); try fc.addLatestMessage(allocator, 4, epoch1_slot, g_root, .full); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // a-branch: f=300, g=200, total through a = 500. // b-branch: e=200, total through b = 200. // a wins → head. Within a: c(300) vs d(200) → c wins → head = f. @@ -3391,7 +3397,7 @@ test "comprehensive multi-phase vote: 8-node tree with switching and slashing" { // Phase 5: Slash V0 → f loses 100. f=200, g=200, e=200. var slashing = makeTestAttesterSlashing(&[_]u64{0}); try fc.onAttesterSlashing(allocator, &.{ .phase0 = &slashing }); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // a-branch: f=200, g=200, total through a = 400. // b-branch: e=200, total through b = 200. // a still wins. Within a: c(200) vs d(200) → root tiebreak d(0x0D) > c(0x0C) → head = g. @@ -3485,7 +3491,7 @@ test "Gloas head integration: EMPTY vs FULL tiebreaker via PTC" { // shouldExtendPayload: boost root = B, B's parent = A, B extends EMPTY → returns false. // EMPTY tiebreaker = 1, FULL tiebreaker = 0 (not timely, extends EMPTY) → EMPTY wins → head through B. fc.proposer_boost_root = b_root; - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(b_root, fc.head.block_root); // Head is B's EMPTY node. try testing.expectEqual(PayloadStatus.empty, fc.head.payload_status); @@ -3494,7 +3500,7 @@ test "Gloas head integration: EMPTY vs FULL tiebreaker via PTC" { // Set all PTC votes to true for block A. fc.proto_array.ptc_votes.getPtr(a_root).?.* = ProtoArray.PtcVotes.initFull(); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // FULL tiebreaker = 2, EMPTY tiebreaker = 1 → FULL wins → head follows C. try testing.expectEqual(c_root, fc.head.block_root); try testing.expectEqual(PayloadStatus.empty, fc.head.payload_status); @@ -3563,7 +3569,7 @@ test "head moves to valid branch after mass invalidation" { try fc.addLatestMessage(allocator, 4, 2, d_root, .full); try fc.addLatestMessage(allocator, 5, 2, d_root, .full); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(c_root, fc.head.block_root); // Phase 2: Invalidate A's branch. LVH = genesis exec hash. @@ -3576,7 +3582,7 @@ test "head moves to valid branch after mass invalidation" { }, 10); // Phase 3: updateHead → head should move to D (only viable branch). - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(d_root, fc.head.block_root); } @@ -3617,7 +3623,7 @@ test "Gloas forked branches attestation shift" { try fc.addLatestMessage(allocator, 0, 1, a_root, .pending); try fc.addLatestMessage(allocator, 1, 1, a_root, .pending); try fc.addLatestMessage(allocator, 2, 1, a_root, .pending); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(a_root, fc.head.block_root); // Phase 2: V3-V6 vote for B → head = B (4*100 > 3*100). @@ -3625,14 +3631,14 @@ test "Gloas forked branches attestation shift" { try fc.addLatestMessage(allocator, 4, 1, b_root, .pending); try fc.addLatestMessage(allocator, 5, 1, b_root, .pending); try fc.addLatestMessage(allocator, 6, 1, b_root, .pending); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(b_root, fc.head.block_root); // Phase 3: V3-V4 switch from B to A at epoch 1 (needs slot >= SLOTS_PER_EPOCH). const epoch1_slot = preset.SLOTS_PER_EPOCH; try fc.addLatestMessage(allocator, 3, epoch1_slot, a_root, .pending); try fc.addLatestMessage(allocator, 4, epoch1_slot, a_root, .pending); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // A now has 5 votes (V0,V1,V2,V3,V4), B has 2 (V5,V6). Head = A. try testing.expectEqual(a_root, fc.head.block_root); } @@ -3928,7 +3934,7 @@ test "onAttestation: valid attestation applies vote (past slot)" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xA1), false); // Validator 0 should now have a vote. Head should be block_root. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(block_root, fc.head.block_root); } @@ -4019,7 +4025,7 @@ test "onAttestation: votes shift head between forks" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xC1), false); } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(a_root, fc.head.block_root); // V3-V5 vote for B → B has 3 votes, A has 3 → tiebreaker. Let's add more so B wins. @@ -4032,7 +4038,7 @@ test "onAttestation: votes shift head between forks" { // Tie: 3*100 vs 3*100. Winner decided by root comparison (higher root wins in tiebreaker). // Regardless of tiebreak, let's verify votes were applied by checking both branches have weight. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // The tiebreaker selects based on root bytes — just verify head is one of them. try testing.expect(std.mem.eql(u8, &fc.head.block_root, &a_root) or std.mem.eql(u8, &fc.head.block_root, &b_root)); } @@ -4068,7 +4074,7 @@ test "onAttestation: epoch advancement allows vote update" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xD1), false); } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(a_root, fc.head.block_root); // Validator 0 switches vote to B in epoch 1 (epoch advances). @@ -4080,7 +4086,7 @@ test "onAttestation: epoch advancement allows vote update" { try fc.onAttestation(allocator, &any_att, hashFromByte(0xD2), false); } - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(b_root, fc.head.block_root); } @@ -4124,7 +4130,7 @@ test "onAttestation: proposer boost outweighs attestation votes" { // Head: A has 128 (1 vote). B has proposer_boost (51). // A should win with 128 > 51. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(a_root, fc.head.block_root); } @@ -4172,7 +4178,7 @@ test "onAttestation: equivocating validator votes are not counted" { } // Head should be B since validator 0's vote was excluded. - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); try testing.expectEqual(b_root, fc.head.block_root); } @@ -4316,7 +4322,7 @@ test "getCanonicalBlockByRoot finds ancestor on canonical chain" { try onBlockFromProto(fc, allocator, makeTestBlock(1, a_root, genesis_root), 10); try onBlockFromProto(fc, allocator, makeTestBlock(2, b_root, a_root), 10); - try fc.updateHead(allocator, std.testing.io); + _ = try fc.updateHead(allocator); // Head should be B (longest chain). try testing.expectEqual(b_root, fc.head.block_root);