diff --git a/pkgs/node/src/chain.zig b/pkgs/node/src/chain.zig index 082a4fdf..2bf2ee5a 100644 --- a/pkgs/node/src/chain.zig +++ b/pkgs/node/src/chain.zig @@ -375,9 +375,6 @@ pub const BeamChain = struct { // This ensures the proposer builds on the latest proposal head derived // from known aggregated payloads. const proposal_head = try self.forkChoice.getProposalHead(opts.slot); - const attestations = try self.forkChoice.getProposalAttestations(); - defer self.allocator.free(attestations); - const parent_root = proposal_head.root; const pre_state = self.states.get(parent_root) orelse return BlockProductionError.MissingPreState; @@ -389,48 +386,30 @@ pub const BeamChain = struct { const post_state = post_state_opt.?; try types.sszClone(self.allocator, types.BeamState, pre_state.*, post_state); - // Use the two-phase aggregation algorithm: - // Phase 1: Collect individual signatures from gossip_signatures - // Phase 2: Fallback to latest_known_aggregated_payloads using greedy set-cover - var aggregation = try types.AggregatedAttestationsResult.init(self.allocator); + const building_timer = zeam_metrics.lean_pq_sig_aggregated_signatures_building_time_seconds.start(); + const proposal_atts = try self.forkChoice.getProposalAttestations(pre_state, opts.slot, opts.proposer_index, parent_root); + _ = building_timer.observe(); + + var agg_attestations = proposal_atts.attestations; var agg_att_cleanup = true; - var agg_sig_cleanup = true; errdefer if (agg_att_cleanup) { - for (aggregation.attestations.slice()) |*att| { - att.deinit(); - } - aggregation.attestations.deinit(); + for (agg_attestations.slice()) |*att| att.deinit(); + agg_attestations.deinit(); }; + + var attestation_signatures = proposal_atts.signatures; + var agg_sig_cleanup = true; errdefer if (agg_sig_cleanup) { - for (aggregation.attestation_signatures.slice()) |*sig| { - sig.deinit(); - } - aggregation.attestation_signatures.deinit(); + for (attestation_signatures.slice()) |*sig| sig.deinit(); + attestation_signatures.deinit(); }; - // Lock mutex only for the duration of computeAggregatedSignatures to avoid deadlock: - // forkChoice.onBlock/updateHead acquire forkChoice.mutex, while onSignedAttestation - // acquires mutex then signatures_mutex. Holding signatures_mutex across onBlock/updateHead - // would allow: (this thread: signatures_mutex -> mutex) vs (gossip: mutex -> signatures_mutex). - { - self.forkChoice.signatures_mutex.lock(); - defer self.forkChoice.signatures_mutex.unlock(); - - const building_timer = zeam_metrics.lean_pq_sig_aggregated_signatures_building_time_seconds.start(); - try aggregation.computeAggregatedSignatures( - attestations, - &pre_state.validators, - &self.forkChoice.gossip_signatures, - &self.forkChoice.latest_known_aggregated_payloads, - ); - _ = building_timer.observe(); - } // Record aggregated signature metrics - const num_agg_sigs = aggregation.attestation_signatures.len(); + const num_agg_sigs = attestation_signatures.len(); zeam_metrics.metrics.lean_pq_sig_aggregated_signatures_total.incrBy(num_agg_sigs); var total_attestations_in_agg: u64 = 0; - for (aggregation.attestations.constSlice()) |agg_att| { + for (agg_attestations.constSlice()) |agg_att| { const bits_len = agg_att.aggregation_bits.len(); for (0..bits_len) |i| { if (agg_att.aggregation_bits.get(i) catch false) { @@ -450,13 +429,12 @@ pub const BeamChain = struct { .state_root = undefined, .body = types.BeamBlockBody{ // .execution_payload_header = .{ .timestamp = timestamp }, - .attestations = aggregation.attestations, + .attestations = agg_attestations, }, }; agg_att_cleanup = false; // Ownership moved to block.body.attestations errdefer block.deinit(); - var attestation_signatures = aggregation.attestation_signatures; agg_sig_cleanup = false; // Ownership moved to attestation_signatures errdefer { for (attestation_signatures.slice()) |*sig_group| { @@ -822,8 +800,14 @@ pub const BeamChain = struct { self.onGossipAggregatedAttestation(signed_aggregation) catch |err| { zeam_metrics.metrics.lean_attestations_invalid_total.incr(.{ .source = "aggregation" }) catch {}; - self.logger.warn("gossip aggregation processing error: {any}", .{err}); - return .{}; + switch (err) { + // Propagate unknown block errors to node.zig for context-aware logging + error.UnknownHeadBlock, error.UnknownSourceBlock, error.UnknownTargetBlock => return err, + else => { + self.logger.warn("gossip aggregation processing error: {any}", .{err}); + return .{}; + }, + } }; zeam_metrics.metrics.lean_attestations_valid_total.incr(.{ .source = "aggregation" }) catch {}; return .{}; @@ -965,7 +949,7 @@ pub const BeamChain = struct { for (validator_indices.items, 0..) |vi, i| { validator_ids[i] = @intCast(vi); } - self.forkChoice.storeAggregatedPayload(validator_ids, &aggregated_attestation.data, signature_proof.*, true) catch |e| { + self.forkChoice.storeAggregatedPayload(&aggregated_attestation.data, signature_proof.*, true) catch |e| { self.logger.warn("failed to store aggregated payload for attestation index={d}: {any}", .{ index, e }); }; } @@ -1466,6 +1450,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); + try self.verifyAggregatedAttestation(signedAggregation); var validator_indices = try types.aggregationBitsToValidatorIndices(&signedAggregation.proof.participants, self.allocator); @@ -1490,7 +1477,7 @@ pub const BeamChain = struct { }; } - try self.forkChoice.storeAggregatedPayload(validator_ids, &signedAggregation.data, signedAggregation.proof, false); + try self.forkChoice.storeAggregatedPayload(&signedAggregation.data, signedAggregation.proof, false); } fn verifyAggregatedAttestation(self: *Self, signedAggregation: types.SignedAggregatedAttestation) !void { @@ -2432,6 +2419,131 @@ test "attestation processing - valid block attestation" { try beam_chain.onGossipAttestation(gossip_attestation); // Verify the attestation data was recorded for aggregation - const data_root = try valid_attestation.message.sszRoot(allocator); - try std.testing.expect(beam_chain.forkChoice.attestation_data_by_root.get(data_root) != null); + try std.testing.expect(beam_chain.forkChoice.gossip_signatures.get(valid_attestation.message) != null); +} + +test "produceBlock - greedy selection by latest slot is suboptimal when attestation references unseen block" { + // Demonstrates that selecting attestation_data entries by latest slot is not the + // best strategy for block production. An attestation_data with a higher slot may + // reference a block on a different fork that this node has never seen locally. + // The STF will skip such attestations (has_known_root check in process_attestations), + // wasting block space. Lower-slot attestations referencing locally-known blocks + // are the ones that actually contribute to justification. + 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, 3, null); + const spec_name = try allocator.dupe(u8, "beamdev"); + const chain_config = configs.ChainConfig{ + .id = configs.Chain.custom, + .genesis = mock_chain.genesis_config, + .spec = .{ + .preset = params.Preset.mainnet, + .name = spec_name, + .attestation_committee_count = 1, + }, + }; + 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 = 0, .logger_config = &zeam_logger_config, .db = db, .node_registry = test_registry }, connected_peers); + defer beam_chain.deinit(); + + // Process blocks at slots 1 and 2 + for (1..mock_chain.blocks.len) |i| { + const signed_block = mock_chain.blocks[i]; + const block = signed_block.message.block; + try beam_chain.forkChoice.onInterval(block.slot * constants.INTERVALS_PER_SLOT, false); + const missing_roots = try beam_chain.onBlock(signed_block, .{ .pruneForkchoice = false }); + allocator.free(missing_roots); + } + + // After processing blocks 0-2, latest_justified should be at slot 1. + const justified_root = mock_chain.latestJustified[2].root; + + // att_data_unseen: higher slot, but references a block on a fork we haven't seen. + // A greedy-by-slot approach would prefer this over lower-slot alternatives. + const unknown_root = [_]u8{0xAB} ** 32; + const att_data_unseen = types.AttestationData{ + .slot = 2, + .head = .{ .root = unknown_root, .slot = 2 }, + .target = .{ .root = unknown_root, .slot = 2 }, + .source = .{ .root = justified_root, .slot = 1 }, + }; + + // att_data_known: references a locally-known block at slot 2. + const att_data_known = types.AttestationData{ + .slot = 1, + .head = .{ .root = mock_chain.blockRoots[2], .slot = 2 }, + .target = .{ .root = mock_chain.blockRoots[2], .slot = 2 }, + .source = .{ .root = justified_root, .slot = 1 }, + }; + + // Create mock proofs with all 4 validators participating + var proof_unseen = try types.AggregatedSignatureProof.init(allocator); + for (0..4) |i| { + try types.aggregationBitsSet(&proof_unseen.participants, i, true); + } + try beam_chain.forkChoice.storeAggregatedPayload(&att_data_unseen, proof_unseen, true); + + var proof_known = try types.AggregatedSignatureProof.init(allocator); + for (0..4) |i| { + try types.aggregationBitsSet(&proof_known.participants, i, true); + } + try beam_chain.forkChoice.storeAggregatedPayload(&att_data_known, proof_known, true); + + // Produce block at slot 3 (proposer_index = 3 % 4 = 3) + const proposal_slot: types.Slot = 3; + const num_validators: u64 = @intCast(mock_chain.genesis_config.numValidators()); + var produced = try beam_chain.produceBlock(.{ + .slot = proposal_slot, + .proposer_index = proposal_slot % num_validators, + }); + defer produced.deinit(); + + // The block should contain attestation entries for both att_data since both + // have source matching the justified checkpoint. + const block_attestations = produced.block.body.attestations.constSlice(); + + // However, after STF processing, only the attestation referencing the known + // block contributes to justification. The unseen-fork attestation is silently + // skipped by process_attestations (has_known_root check). + // + // This demonstrates why greedy-by-latest-slot is suboptimal: if we had only + // selected the highest-slot attestation (att_data_unseen at slot=2), the block + // would contribute zero attestation weight. The lower-slot attestation + // (att_data_known at slot=1) is the one that actually matters. + const post_state = beam_chain.states.get(produced.blockRoot) orelse @panic("post state should exist"); + try std.testing.expect(post_state.latest_justified.slot >= 1); + + // Count how many attestation entries reference the unseen vs known block + var unseen_count: usize = 0; + var known_count: usize = 0; + for (block_attestations) |att| { + if (std.mem.eql(u8, &att.data.target.root, &unknown_root)) { + unseen_count += 1; + } else if (std.mem.eql(u8, &att.data.target.root, &mock_chain.blockRoots[2])) { + known_count += 1; + } + } + // Only the known attestation is included in the block + try std.testing.expect(unseen_count == 0); + try std.testing.expect(known_count > 0); } diff --git a/pkgs/node/src/forkchoice.zig b/pkgs/node/src/forkchoice.zig index bbe2aff3..c806df12 100644 --- a/pkgs/node/src/forkchoice.zig +++ b/pkgs/node/src/forkchoice.zig @@ -264,7 +264,6 @@ pub const ForkChoiceParams = struct { }; // Use shared signature map types from types package -const SignatureKey = types.SignatureKey; const StoredSignature = types.StoredSignature; const SignaturesMap = types.SignaturesMap; const StoredAggregatedPayload = types.StoredAggregatedPayload; @@ -293,11 +292,9 @@ pub const ForkChoice = struct { logger: zeam_utils.ModuleLogger, // Thread-safe access protection mutex: Thread.RwLock, - // Per-validator XMSS signatures learned from gossip, keyed by (validator_id, attestation_data_root) + // Per-validator XMSS signatures learned from gossip, keyed by AttestationData. + // Each AttestationData maps to a per-validator-id inner map of signatures. gossip_signatures: SignaturesMap, - // Attestation data indexed by data root, used to reconstruct attestations from payloads. - // Entries are pruned once their target checkpoint is at or before finalization. - attestation_data_by_root: std.AutoHashMap(types.Root, types.AttestationData), // Aggregated signature proofs pending processing. // These payloads are "new" and migrate to known payloads via interval ticks. latest_new_aggregated_payloads: AggregatedPayloadsMap, @@ -360,7 +357,6 @@ pub const ForkChoice = struct { const attestations = std.AutoHashMap(usize, AttestationTracker).init(allocator); const deltas: std.ArrayList(isize) = .empty; const gossip_signatures = SignaturesMap.init(allocator); - const attestation_data_by_root = std.AutoHashMap(types.Root, types.AttestationData).init(allocator); const latest_new_aggregated_payloads = AggregatedPayloadsMap.init(allocator); const latest_known_aggregated_payloads = AggregatedPayloadsMap.init(allocator); @@ -377,7 +373,6 @@ pub const ForkChoice = struct { .logger = opts.logger, .mutex = Thread.RwLock{}, .gossip_signatures = gossip_signatures, - .attestation_data_by_root = attestation_data_by_root, .latest_new_aggregated_payloads = latest_new_aggregated_payloads, .latest_known_aggregated_payloads = latest_known_aggregated_payloads, .signatures_mutex = .{}, @@ -473,7 +468,6 @@ pub const ForkChoice = struct { self.signatures_mutex.lock(); defer self.signatures_mutex.unlock(); self.gossip_signatures.deinit(); - self.attestation_data_by_root.deinit(); // Deinit each list in the aggregated payloads maps var it_known = self.latest_known_aggregated_payloads.iterator(); @@ -937,31 +931,174 @@ pub const ForkChoice = struct { } // Internal unlocked version - assumes caller holds lock - fn getProposalAttestationsUnlocked(self: *Self) ![]types.Attestation { - var included_attestations: std.ArrayList(types.Attestation) = .empty; - - const latest_justified = self.fcStore.latest_justified; - - // TODO naive strategy to include all attestations that are consistent with the latest justified - // replace by the other mini 3sf simple strategy to loop and see if justification happens and - // till no further attestations can be added - var att_iter = self.attestations.iterator(); - while (att_iter.next()) |entry| { - const validator_id = entry.key_ptr.*; - const attestation_data = (entry.value_ptr.latestKnown orelse ProtoAttestation{}).attestation_data; - - if (attestation_data) |att_data| { - if (std.mem.eql(u8, &latest_justified.root, &att_data.source.root)) { - const attestation = types.Attestation{ - .data = att_data, - .validator_id = validator_id, - }; - try included_attestations.append(self.allocator, attestation); + pub const ProposalAttestationsResult = struct { + attestations: types.AggregatedAttestations, + signatures: types.AttestationSignatures, + }; + + fn getProposalAttestationsUnlocked( + self: *Self, + pre_state: *const types.BeamState, + slot: types.Slot, + proposer_index: types.ValidatorIndex, + parent_root: [32]u8, + ) !ProposalAttestationsResult { + var agg_attestations = try types.AggregatedAttestations.init(self.allocator); + var agg_att_cleanup = true; + errdefer if (agg_att_cleanup) { + for (agg_attestations.slice()) |*att| att.deinit(); + agg_attestations.deinit(); + }; + + var attestation_signatures = try types.AttestationSignatures.init(self.allocator); + var agg_sig_cleanup = true; + errdefer if (agg_sig_cleanup) { + for (attestation_signatures.slice()) |*sig| sig.deinit(); + attestation_signatures.deinit(); + }; + + // Fixed-point attestation collection with greedy proof selection. + // + // For the current latest_justified checkpoint, find matching attestation_data + // entries in latest_known_aggregated_payloads and greedily select proofs that + // maximize new validator coverage. Then apply STF to check if justification + // changed. If it did, look for entries matching the new justified checkpoint + // and repeat. If no matching entries exist or justification did not change, + // block production is done. + var current_justified_root = pre_state.latest_justified.root; + var processed_att_data = std.AutoHashMap(types.AttestationData, void).init(self.allocator); + defer processed_att_data.deinit(); + + while (true) { + // Find all attestation_data entries whose source matches the current justified checkpoint + // and greedily select proofs maximizing new validator coverage for each. + // Collect entries and sort by target slot for deterministic processing order. + const MapEntry = struct { + att_data: *types.AttestationData, + payloads: *types.AggregatedPayloadsList, + }; + var sorted_entries: std.ArrayList(MapEntry) = .empty; + defer sorted_entries.deinit(self.allocator); + + var payload_it = self.latest_known_aggregated_payloads.iterator(); + while (payload_it.next()) |entry| { + if (!std.mem.eql(u8, ¤t_justified_root, &entry.key_ptr.source.root)) continue; + if (!self.protoArray.indices.contains(entry.key_ptr.head.root)) continue; + if (processed_att_data.contains(entry.key_ptr.*)) continue; + try sorted_entries.append(self.allocator, .{ .att_data = entry.key_ptr, .payloads = entry.value_ptr }); + } + + std.mem.sort(MapEntry, sorted_entries.items, {}, struct { + fn lessThan(_: void, a: MapEntry, b: MapEntry) bool { + return a.att_data.target.slot < b.att_data.target.slot; + } + }.lessThan); + + const found_entries = sorted_entries.items.len > 0; + + for (sorted_entries.items) |map_entry| { + try processed_att_data.put(map_entry.att_data.*, {}); + + const att_data = map_entry.att_data.*; + const payloads = map_entry.payloads; + + // Greedy proof selection: each iteration picks the proof covering + // the most uncovered validators until all are covered. + var covered = try std.DynamicBitSet.initEmpty(self.allocator, 0); + defer covered.deinit(); + + while (true) { + var best_proof: ?*const types.AggregatedSignatureProof = null; + var best_new_coverage: usize = 0; + + for (payloads.items) |*stored| { + var new_coverage: usize = 0; + for (0..stored.proof.participants.len()) |i| { + if (stored.proof.participants.get(i) catch false) { + if (i >= covered.capacity() or !covered.isSet(i)) { + new_coverage += 1; + } + } + } + if (new_coverage > best_new_coverage) { + best_new_coverage = new_coverage; + best_proof = &stored.proof; + } + } + + if (best_proof == null or best_new_coverage == 0) break; + + var cloned_proof: types.AggregatedSignatureProof = undefined; + try types.sszClone(self.allocator, types.AggregatedSignatureProof, best_proof.?.*, &cloned_proof); + errdefer cloned_proof.deinit(); + + var att_bits = try types.AggregationBits.init(self.allocator); + errdefer att_bits.deinit(); + + for (0..cloned_proof.participants.len()) |i| { + if (cloned_proof.participants.get(i) catch false) { + try types.aggregationBitsSet(&att_bits, i, true); + if (i >= covered.capacity()) { + try covered.resize(i + 1, false); + } + covered.set(i); + } + } + + try agg_attestations.append(.{ .aggregation_bits = att_bits, .data = att_data }); + try attestation_signatures.append(cloned_proof); + } + } + + if (!found_entries) break; + + // Build candidate block with all accumulated attestations and apply STF + // to check if justification changed. + var candidate_atts = try types.AggregatedAttestations.init(self.allocator); + defer { + for (candidate_atts.slice()) |*att| att.deinit(); + candidate_atts.deinit(); + } + + for (agg_attestations.constSlice()) |agg_att| { + var cloned_bits = try types.AggregationBits.init(self.allocator); + errdefer cloned_bits.deinit(); + for (0..agg_att.aggregation_bits.len()) |i| { + if (agg_att.aggregation_bits.get(i) catch false) { + try types.aggregationBitsSet(&cloned_bits, i, true); + } } + try candidate_atts.append(.{ .aggregation_bits = cloned_bits, .data = agg_att.data }); + } + + const candidate_block = types.BeamBlock{ + .slot = slot, + .proposer_index = proposer_index, + .parent_root = parent_root, + .state_root = std.mem.zeroes([32]u8), + .body = .{ .attestations = candidate_atts }, + }; + + var candidate_state: types.BeamState = undefined; + try types.sszClone(self.allocator, types.BeamState, pre_state.*, &candidate_state); + defer candidate_state.deinit(); + + try candidate_state.process_slots(self.allocator, slot, self.logger); + try candidate_state.process_block(self.allocator, candidate_block, self.logger, null); + + if (!std.mem.eql(u8, &candidate_state.latest_justified.root, ¤t_justified_root)) { + // Justification changed - look for entries matching the new checkpoint + current_justified_root = candidate_state.latest_justified.root; + continue; } + + // Justification unchanged or no new entries - block production done + break; } - return included_attestations.toOwnedSlice(self.allocator); + agg_att_cleanup = false; + agg_sig_cleanup = false; + return .{ .attestations = agg_attestations, .signatures = attestation_signatures }; } // Internal unlocked version - assumes caller holds lock @@ -1153,20 +1290,12 @@ pub const ForkChoice = struct { const validator_id = signed_attestation.validator_id; const attestation_slot = attestation_data.slot; - // Store attestation data by root for later aggregation - const data_root = try attestation_data.sszRoot(self.allocator); var gossip_signatures_count: usize = 0; { self.signatures_mutex.lock(); defer self.signatures_mutex.unlock(); - try self.attestation_data_by_root.put(data_root, attestation_data); - // Store the gossip signature for later aggregation - const sig_key = SignatureKey{ - .validator_id = validator_id, - .data_root = data_root, - }; - try self.gossip_signatures.put(sig_key, .{ + try self.gossip_signatures.addSignature(attestation_data, validator_id, .{ .slot = attestation_slot, .signature = signed_attestation.signature, }); @@ -1233,43 +1362,32 @@ pub const ForkChoice = struct { /// For gossip attestations, also updates fork choice attestation trackers. pub fn storeAggregatedPayload( self: *Self, - validator_ids: []const types.ValidatorIndex, attestation_data: *const types.AttestationData, proof: types.AggregatedSignatureProof, is_from_block: bool, ) !void { - const data_root = try attestation_data.sszRoot(self.allocator); + var cloned_proof: types.AggregatedSignatureProof = undefined; + try types.sszClone(self.allocator, types.AggregatedSignatureProof, proof, &cloned_proof); + errdefer cloned_proof.deinit(); { self.signatures_mutex.lock(); defer self.signatures_mutex.unlock(); - try self.attestation_data_by_root.put(data_root, attestation_data.*); - const target_map = if (is_from_block) &self.latest_known_aggregated_payloads else &self.latest_new_aggregated_payloads; - for (validator_ids) |validator_id| { - const sig_key = SignatureKey{ - .validator_id = validator_id, - .data_root = data_root, - }; - const gop = try target_map.getOrPut(sig_key); - if (!gop.found_existing) { - gop.value_ptr.* = .empty; - } - - var cloned_proof: types.AggregatedSignatureProof = undefined; - try types.sszClone(self.allocator, types.AggregatedSignatureProof, proof, &cloned_proof); - errdefer cloned_proof.deinit(); - - try gop.value_ptr.append(self.allocator, .{ - .slot = attestation_data.slot, - .proof = cloned_proof, - }); + const gop = try target_map.getOrPut(attestation_data.*); + if (!gop.found_existing) { + gop.value_ptr.* = .empty; } + + try gop.value_ptr.append(self.allocator, .{ + .slot = attestation_data.slot, + .proof = cloned_proof, + }); } } @@ -1279,14 +1397,11 @@ pub const ForkChoice = struct { const state = state_opt orelse return try self.allocator.alloc(types.SignedAggregatedAttestation, 0); - var attestations: std.ArrayList(types.Attestation) = .{}; - defer attestations.deinit(self.allocator); - // Capture counts for metrics update outside lock scope var new_payloads_count: usize = 0; var gossip_sigs_count: usize = 0; - var results: std.ArrayList(types.SignedAggregatedAttestation) = .{}; + var results: std.ArrayList(types.SignedAggregatedAttestation) = .empty; errdefer { for (results.items) |*signed| { signed.deinit(); @@ -1298,92 +1413,53 @@ pub const ForkChoice = struct { self.signatures_mutex.lock(); defer self.signatures_mutex.unlock(); - var sig_it = self.gossip_signatures.iterator(); - while (sig_it.next()) |entry| { - const sig_key = entry.key_ptr.*; - const attestation_data = self.attestation_data_by_root.get(sig_key.data_root) orelse continue; - try attestations.append(self.allocator, .{ - .validator_id = sig_key.validator_id, - .data = attestation_data, - }); - } + // Collect keys first to avoid modifying map during iteration + var att_data_keys: std.ArrayList(types.AttestationData) = .empty; + defer att_data_keys.deinit(self.allocator); - var aggregation = try types.AggregatedAttestationsResult.init(self.allocator); - var agg_att_cleanup = true; - var agg_sig_cleanup = true; - errdefer if (agg_att_cleanup) { - for (aggregation.attestations.slice()) |*att| { - att.deinit(); - } - aggregation.attestations.deinit(); - }; - errdefer if (agg_sig_cleanup) { - for (aggregation.attestation_signatures.slice()) |*sig| { - sig.deinit(); + { + var it = self.gossip_signatures.iterator(); + while (it.next()) |entry| { + try att_data_keys.append(self.allocator, entry.key_ptr.*); } - aggregation.attestation_signatures.deinit(); - }; - - try aggregation.computeAggregatedSignatures( - attestations.items, - &state.validators, - &self.gossip_signatures, - null, - ); - - const agg_attestations = aggregation.attestations.constSlice(); - const agg_signatures = aggregation.attestation_signatures.constSlice(); - - for (agg_attestations, 0..) |agg_att, index| { - const proof = agg_signatures[index]; - const data_root = try agg_att.data.sszRoot(self.allocator); + } - try self.attestation_data_by_root.put(data_root, agg_att.data); + for (att_data_keys.items) |att_data| { + const inner_map_ptr = self.gossip_signatures.getPtr(att_data) orelse continue; - var validator_indices = try types.aggregationBitsToValidatorIndices(&proof.participants, self.allocator); - defer validator_indices.deinit(self.allocator); + var proof = try types.aggregateInnerMap(self.allocator, inner_map_ptr, att_data, &state.validators); + errdefer proof.deinit(); - for (validator_indices.items) |validator_index| { - const sig_key = SignatureKey{ - .validator_id = @intCast(validator_index), - .data_root = data_root, - }; - const gop = try self.latest_new_aggregated_payloads.getOrPut(sig_key); - if (!gop.found_existing) { - gop.value_ptr.* = .empty; - } + // Store proof keyed by AttestationData + const gop = try self.latest_new_aggregated_payloads.getOrPut(att_data); + if (!gop.found_existing) { + gop.value_ptr.* = .empty; + } + { var cloned_proof: types.AggregatedSignatureProof = undefined; try types.sszClone(self.allocator, types.AggregatedSignatureProof, proof, &cloned_proof); errdefer cloned_proof.deinit(); try gop.value_ptr.append(self.allocator, .{ - .slot = agg_att.data.slot, + .slot = att_data.slot, .proof = cloned_proof, }); - // Align with leanSpec: once this signature is represented by an aggregated - // payload, remove it from the gossip signature map to prevent re-aggregation. - _ = self.gossip_signatures.remove(sig_key); } + // Align with leanSpec: once signatures for this data are represented by an + // aggregated payload, remove the whole inner map to prevent re-aggregation. + self.gossip_signatures.removeAndDeinit(att_data); + var output_proof: types.AggregatedSignatureProof = undefined; try types.sszClone(self.allocator, types.AggregatedSignatureProof, proof, &output_proof); errdefer output_proof.deinit(); try results.append(self.allocator, .{ - .data = agg_att.data, + .data = att_data, .proof = output_proof, }); - } - agg_att_cleanup = false; - agg_sig_cleanup = false; - for (aggregation.attestations.slice()) |*att| { - att.deinit(); + proof.deinit(); } - aggregation.attestations.deinit(); - for (aggregation.attestation_signatures.slice()) |*sig| { - sig.deinit(); - } - aggregation.attestation_signatures.deinit(); // Capture counts before lock is released new_payloads_count = self.latest_new_aggregated_payloads.count(); @@ -1418,45 +1494,27 @@ pub const ForkChoice = struct { self.signatures_mutex.lock(); defer self.signatures_mutex.unlock(); - var stale_roots = std.AutoHashMap(types.Root, void).init(self.allocator); - defer stale_roots.deinit(); - - var data_it = self.attestation_data_by_root.iterator(); - while (data_it.next()) |entry| { - if (entry.value_ptr.target.slot <= finalized_slot) { - try stale_roots.put(entry.key_ptr.*, {}); - } - } - - if (stale_roots.count() == 0) return; - - // Remove stale attestation data entries. - var stale_it = stale_roots.iterator(); - while (stale_it.next()) |entry| { - _ = self.attestation_data_by_root.remove(entry.key_ptr.*); - } - - // Remove gossip signatures tied to stale data roots. - var gossip_keys_to_remove: std.ArrayList(SignatureKey) = .empty; + // Collect stale AttestationData keys from gossip_signatures (target.slot <= finalized) + var gossip_keys_to_remove: std.ArrayList(types.AttestationData) = .empty; defer gossip_keys_to_remove.deinit(self.allocator); var gossip_it = self.gossip_signatures.iterator(); while (gossip_it.next()) |entry| { - if (stale_roots.contains(entry.key_ptr.data_root)) { + if (entry.key_ptr.target.slot <= finalized_slot) { try gossip_keys_to_remove.append(self.allocator, entry.key_ptr.*); } } - for (gossip_keys_to_remove.items) |sig_key| { - _ = self.gossip_signatures.remove(sig_key); + + for (gossip_keys_to_remove.items) |data| { + self.gossip_signatures.removeAndDeinit(data); } - const removed_known = try prunePayloadMapByRoots(self.allocator, &self.latest_known_aggregated_payloads, &stale_roots); - const removed_new = try prunePayloadMapByRoots(self.allocator, &self.latest_new_aggregated_payloads, &stale_roots); + const removed_known = try prunePayloadMapBySlot(self.allocator, &self.latest_known_aggregated_payloads, finalized_slot); + const removed_new = try prunePayloadMapBySlot(self.allocator, &self.latest_new_aggregated_payloads, finalized_slot); self.logger.debug( - "pruned stale attestation data: roots={d} gossip={d} payloads_known={d} payloads_new={d} finalized_slot={d}", + "pruned stale attestation data: gossip={d} payloads_known={d} payloads_new={d} finalized_slot={d}", .{ - stale_roots.count(), gossip_keys_to_remove.items.len, removed_known, removed_new, @@ -1465,18 +1523,18 @@ pub const ForkChoice = struct { ); } - fn prunePayloadMapByRoots( + fn prunePayloadMapBySlot( allocator: Allocator, payloads: *AggregatedPayloadsMap, - stale_roots: *const std.AutoHashMap(types.Root, void), + finalized_slot: types.Slot, ) !usize { - var keys_to_remove: std.ArrayList(SignatureKey) = .{}; + var keys_to_remove: std.ArrayList(types.AttestationData) = .{}; defer keys_to_remove.deinit(allocator); var removed_total: usize = 0; var it = payloads.iterator(); while (it.next()) |entry| { - if (!stale_roots.contains(entry.key_ptr.data_root)) continue; + if (entry.key_ptr.target.slot > finalized_slot) continue; for (entry.value_ptr.items) |*stored| { stored.proof.deinit(); @@ -1485,8 +1543,8 @@ pub const ForkChoice = struct { try keys_to_remove.append(allocator, entry.key_ptr.*); } - for (keys_to_remove.items) |sig_key| { - if (payloads.fetchRemove(sig_key)) |kv| { + for (keys_to_remove.items) |data| { + if (payloads.fetchRemove(data)) |kv| { var mutable_val = kv.value; mutable_val.deinit(allocator); } @@ -1627,10 +1685,16 @@ pub const ForkChoice = struct { // READ-ONLY API - SHARED LOCK - pub fn getProposalAttestations(self: *Self) ![]types.Attestation { + pub fn getProposalAttestations( + self: *Self, + pre_state: *const types.BeamState, + slot: types.Slot, + proposer_index: types.ValidatorIndex, + parent_root: [32]u8, + ) !ProposalAttestationsResult { self.mutex.lockShared(); defer self.mutex.unlockShared(); - return self.getProposalAttestationsUnlocked(); + return self.getProposalAttestationsUnlocked(pre_state, slot, proposer_index, parent_root); } pub fn getAttestationTarget(self: *Self) !types.Checkpoint { @@ -1858,7 +1922,7 @@ test "forkchoice block tree" { test "aggregateCommitteeSignatures prunes aggregated gossip signatures" { const allocator = std.testing.allocator; - const validator_count: usize = 4; + const validator_count: usize = 8; const num_blocks: usize = 1; var key_manager = try keymanager.getTestKeyManager(allocator, validator_count, num_blocks); @@ -1915,19 +1979,32 @@ test "aggregateCommitteeSignatures prunes aggregated gossip signatures" { .slot = 0, }, }; - const attestation = types.Attestation{ - .validator_id = 0, - .data = attestation_data, - }; - const signature = try key_manager.signAttestation(&attestation, allocator); + var found_unsorted = false; + for (0..validator_count) |validator_id| { + const attestation = types.Attestation{ + .validator_id = validator_id, + .data = attestation_data, + }; + const signature = try key_manager.signAttestation(&attestation, allocator); - try fork_choice.onSignedAttestation(.{ - .validator_id = 0, - .message = attestation_data, - .signature = signature, - }); + try fork_choice.onSignedAttestation(.{ + .validator_id = validator_id, + .message = attestation_data, + .signature = signature, + }); + + if (!found_unsorted) { + const inner_map_ptr = fork_choice.gossip_signatures.getPtr(attestation_data) orelse continue; + const iter_order = try collectInnerMapOrder(allocator, inner_map_ptr); + defer allocator.free(iter_order); + if (iter_order.len >= 2 and !isSortedAsc(iter_order)) { + found_unsorted = true; + break; + } + } + } + try std.testing.expect(found_unsorted); - const data_root = try attestation_data.sszRoot(allocator); const aggregations = try fork_choice.aggregateCommitteeSignatures(&mock_chain.genesis_state); defer { for (aggregations) |*signed_aggregation| { @@ -1938,10 +2015,48 @@ test "aggregateCommitteeSignatures prunes aggregated gossip signatures" { try std.testing.expectEqual(@as(usize, 1), aggregations.len); try std.testing.expectEqual(@as(usize, 0), fork_choice.gossip_signatures.count()); - try std.testing.expect(fork_choice.latest_new_aggregated_payloads.get(.{ - .validator_id = 0, - .data_root = data_root, - }) != null); + try std.testing.expect(fork_choice.latest_new_aggregated_payloads.get(attestation_data) != null); + + const aggregation = aggregations[0]; + var validator_indices = try types.aggregationBitsToValidatorIndices(&aggregation.proof.participants, allocator); + defer validator_indices.deinit(allocator); + + const xmss_mod = @import("@zeam/xmss"); + const pk_handles = try allocator.alloc(*const xmss_mod.HashSigPublicKey, validator_indices.items.len); + defer allocator.free(pk_handles); + + for (validator_indices.items, 0..) |validator_index, i| { + pk_handles[i] = try key_manager.getPublicKeyHandle(validator_index); + } + + var message_hash: [32]u8 = undefined; + try zeam_utils.hashTreeRoot(types.AttestationData, aggregation.data, &message_hash, allocator); + try aggregation.proof.verify(pk_handles, &message_hash, aggregation.data.slot); +} + +fn collectInnerMapOrder( + allocator: Allocator, + inner_map: *const types.SignaturesMap.InnerMap, +) ![]usize { + const len = inner_map.count(); + const order = try allocator.alloc(usize, len); + var idx: usize = 0; + var it = inner_map.iterator(); + while (it.next()) |entry| { + order[idx] = @intCast(entry.key_ptr.*); + idx += 1; + } + return order; +} + +fn isSortedAsc(values: []const usize) bool { + if (values.len <= 1) return true; + var prev = values[0]; + for (values[1..]) |value| { + if (value < prev) return false; + prev = value; + } + return true; } // Helper function to create a deterministic test root filled with a specific byte @@ -2103,7 +2218,6 @@ test "getCanonicalAncestorAtDepth and getCanonicalityAnalysis" { .logger = module_logger, .mutex = Thread.RwLock{}, .gossip_signatures = SignaturesMap.init(allocator), - .attestation_data_by_root = std.AutoHashMap(types.Root, types.AttestationData).init(allocator), .latest_new_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .latest_known_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .signatures_mutex = std.Thread.Mutex{}, @@ -2112,7 +2226,6 @@ test "getCanonicalAncestorAtDepth and getCanonicalityAnalysis" { defer fork_choice.attestations.deinit(); defer fork_choice.deltas.deinit(fork_choice.allocator); defer fork_choice.gossip_signatures.deinit(); - defer fork_choice.attestation_data_by_root.deinit(); defer deinitAggregatedPayloadsMap(allocator, &fork_choice.latest_known_aggregated_payloads); defer deinitAggregatedPayloadsMap(allocator, &fork_choice.latest_new_aggregated_payloads); @@ -2378,8 +2491,7 @@ fn stageAggregatedAttestation( try types.aggregationBitsSet(&proof.participants, @intCast(signed_attestation.validator_id), true); - const validator_ids = [_]types.ValidatorIndex{signed_attestation.validator_id}; - try fork_choice.storeAggregatedPayload(&validator_ids, &signed_attestation.message, proof, false); + try fork_choice.storeAggregatedPayload(&signed_attestation.message, proof, false); } // Rebase tests build ForkChoice structs in helper functions that outlive the helper scope. @@ -2456,7 +2568,6 @@ fn buildTestTreeWithMockChain(allocator: Allocator, mock_chain: anytype) !struct .logger = module_logger, .mutex = Thread.RwLock{}, .gossip_signatures = SignaturesMap.init(allocator), - .attestation_data_by_root = std.AutoHashMap(types.Root, types.AttestationData).init(allocator), .latest_new_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .latest_known_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .signatures_mutex = std.Thread.Mutex{}, @@ -2493,7 +2604,6 @@ const RebaseTestContext = struct { errdefer test_data.fork_choice.attestations.deinit(); errdefer test_data.fork_choice.deltas.deinit(test_data.fork_choice.allocator); errdefer test_data.fork_choice.gossip_signatures.deinit(); - errdefer test_data.fork_choice.attestation_data_by_root.deinit(); errdefer test_data.fork_choice.latest_known_aggregated_payloads.deinit(); errdefer test_data.fork_choice.latest_new_aggregated_payloads.deinit(); @@ -2512,7 +2622,6 @@ const RebaseTestContext = struct { self.fork_choice.attestations.deinit(); self.fork_choice.deltas.deinit(self.allocator); self.fork_choice.gossip_signatures.deinit(); - self.fork_choice.attestation_data_by_root.deinit(); // Deinit each list in latest_known_aggregated_payloads var it_known = self.fork_choice.latest_known_aggregated_payloads.iterator(); while (it_known.next()) |entry| { @@ -3433,7 +3542,6 @@ test "rebase: heavy attestation load - all validators tracked correctly" { .logger = module_logger, .mutex = Thread.RwLock{}, .gossip_signatures = SignaturesMap.init(allocator), - .attestation_data_by_root = std.AutoHashMap(types.Root, types.AttestationData).init(allocator), .latest_new_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .latest_known_aggregated_payloads = AggregatedPayloadsMap.init(allocator), .signatures_mutex = std.Thread.Mutex{}, @@ -3444,7 +3552,6 @@ test "rebase: heavy attestation load - all validators tracked correctly" { defer fork_choice.attestations.deinit(); defer fork_choice.deltas.deinit(fork_choice.allocator); defer fork_choice.gossip_signatures.deinit(); - defer fork_choice.attestation_data_by_root.deinit(); defer deinitAggregatedPayloadsMap(allocator, &fork_choice.latest_known_aggregated_payloads); defer deinitAggregatedPayloadsMap(allocator, &fork_choice.latest_new_aggregated_payloads); diff --git a/pkgs/spectest/src/runner/fork_choice_runner.zig b/pkgs/spectest/src/runner/fork_choice_runner.zig index 590910d5..8fbed4b1 100644 --- a/pkgs/spectest/src/runner/fork_choice_runner.zig +++ b/pkgs/spectest/src/runner/fork_choice_runner.zig @@ -781,7 +781,7 @@ fn processBlockStep( validator_ids[i] = @intCast(vi); } - ctx.fork_choice.storeAggregatedPayload(validator_ids, &aggregated_attestation.data, proof_template, true) catch |err| { + ctx.fork_choice.storeAggregatedPayload(&aggregated_attestation.data, proof_template, true) 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) }, @@ -819,9 +819,6 @@ fn processBlockStep( // Proposer attestation is treated as gossip and queued as a new aggregated payload. try ctx.fork_choice.onSignedAttestation(signed_attestation); - const proposer_data_root = try proposer_attestation.data.sszRoot(ctx.allocator); - try ctx.fork_choice.attestation_data_by_root.put(proposer_data_root, proposer_attestation.data); - var proposer_proof = types.AggregatedSignatureProof.init(ctx.allocator) catch |err| { std.debug.print( "fixture {s} case {s}{f}: failed to init proposer proof ({s})\n", @@ -839,11 +836,7 @@ fn processBlockStep( return FixtureError.InvalidFixture; }; - const sig_key = types.SignatureKey{ - .validator_id = proposer_attestation.validator_id, - .data_root = proposer_data_root, - }; - const gop = try ctx.fork_choice.latest_new_aggregated_payloads.getOrPut(sig_key); + const gop = try ctx.fork_choice.latest_new_aggregated_payloads.getOrPut(proposer_attestation.data); if (!gop.found_existing) { gop.value_ptr.* = .empty; } diff --git a/pkgs/state-transition/src/mock.zig b/pkgs/state-transition/src/mock.zig index 7d72e7b3..10b10fab 100644 --- a/pkgs/state-transition/src/mock.zig +++ b/pkgs/state-transition/src/mock.zig @@ -6,7 +6,6 @@ const params = @import("@zeam/params"); const types = @import("@zeam/types"); const zeam_utils = @import("@zeam/utils"); const keymanager = @import("@zeam/key-manager"); -const xmss = @import("@zeam/xmss"); const transition = @import("./transition.zig"); @@ -277,7 +276,7 @@ pub fn genMockChain(allocator: Allocator, numBlocks: usize, from_genesis: ?types else => unreachable, } - // Build gossip signatures map from attestations + // Build gossip signatures map from attestations (keyed by AttestationData) var signatures_map = types.SignaturesMap.init(allocator); defer signatures_map.deinit(); @@ -285,37 +284,46 @@ pub fn genMockChain(allocator: Allocator, numBlocks: usize, from_genesis: ?types // Get the serialized signature bytes const sig_buffer = try key_manager.signAttestation(&attestation, allocator); - // Compute data root for the signature key - const data_root = try attestation.data.sszRoot(allocator); - - try signatures_map.put( - .{ .validator_id = attestation.validator_id, .data_root = data_root }, - .{ .slot = attestation.data.slot, .signature = sig_buffer }, - ); + try signatures_map.addSignature(attestation.data, attestation.validator_id, .{ + .slot = attestation.data.slot, + .signature = sig_buffer, + }); } - // Compute aggregated signatures using the shared method - var aggregation = try types.AggregatedAttestationsResult.init(allocator); + // Compute aggregated signatures directly from signatures map + var agg_attestations = try types.AggregatedAttestations.init(allocator); var agg_att_cleanup = true; - var agg_sig_cleanup = true; errdefer if (agg_att_cleanup) { - for (aggregation.attestations.slice()) |*att| { - att.deinit(); - } - aggregation.attestations.deinit(); + for (agg_attestations.slice()) |*att| att.deinit(); + agg_attestations.deinit(); }; + + var agg_signatures = try types.AttestationSignatures.init(allocator); + var agg_sig_cleanup = true; errdefer if (agg_sig_cleanup) { - for (aggregation.attestation_signatures.slice()) |*sig| { - sig.deinit(); - } - aggregation.attestation_signatures.deinit(); + for (agg_signatures.slice()) |*sig| sig.deinit(); + agg_signatures.deinit(); }; - try aggregation.computeAggregatedSignatures( - attestations.items, - &beam_state.validators, - &signatures_map, - null, // no pre-aggregated payloads in mock - ); + + var sig_it = signatures_map.iterator(); + while (sig_it.next()) |entry| { + const att_data = entry.key_ptr.*; + + var proof = try types.aggregateInnerMap(allocator, entry.value_ptr, att_data, &beam_state.validators); + errdefer proof.deinit(); + + // Clone participants for the attestation entry + var att_bits = try types.AggregationBits.init(allocator); + errdefer att_bits.deinit(); + for (0..proof.participants.len()) |i| { + if (proof.participants.get(i) catch false) { + try types.aggregationBitsSet(&att_bits, i, true); + } + } + + try agg_attestations.append(.{ .aggregation_bits = att_bits, .data = att_data }); + try agg_signatures.append(proof); + } const proposer_index = slot % genesis_config.numValidators(); var block = types.BeamBlock{ @@ -324,7 +332,7 @@ pub fn genMockChain(allocator: Allocator, numBlocks: usize, from_genesis: ?types .parent_root = parent_root, .state_root = state_root, .body = types.BeamBlockBody{ - .attestations = aggregation.attestations, + .attestations = agg_attestations, }, }; agg_att_cleanup = false; @@ -360,7 +368,7 @@ pub fn genMockChain(allocator: Allocator, numBlocks: usize, from_genesis: ?types ); const block_signatures = types.BlockSignatures{ - .attestation_signatures = aggregation.attestation_signatures, + .attestation_signatures = agg_signatures, .proposer_signature = proposer_sig, }; agg_sig_cleanup = false; diff --git a/pkgs/types/src/block.zig b/pkgs/types/src/block.zig index a29da5b4..6cd0250b 100644 --- a/pkgs/types/src/block.zig +++ b/pkgs/types/src/block.zig @@ -25,7 +25,6 @@ const SIGSIZE = utils.SIGSIZE; const Root = utils.Root; const ZERO_HASH = utils.ZERO_HASH; const ZERO_SIGBYTES = utils.ZERO_SIGBYTES; -const Validators = validator.Validators; const bytesToHex = utils.BytesToHex; const json = std.json; @@ -33,20 +32,176 @@ const json = std.json; const freeJsonValue = utils.freeJsonValue; // signatures_map types for aggregation -/// SignatureKey is used to index signatures by (validator_id, data_root). -pub const SignatureKey = struct { - validator_id: ValidatorIndex, - data_root: Root, -}; -/// Stored signatures_map entry +/// Stored signatures_map entry: per-validator signature + slot metadata. pub const StoredSignature = struct { slot: Slot, signature: SIGBYTES, }; -/// Map type for signatures_map: SignatureKey -> individual XMSS signature bytes + slot metadata -pub const SignaturesMap = std.AutoHashMap(SignatureKey, StoredSignature); +/// Map type for gossip signatures: AttestationData -> per-validator signatures. +/// Wraps AutoHashMap to manage the lifecycle of inner maps and provide +/// convenience helpers for common operations. +pub const SignaturesMap = struct { + pub const InnerMap = std.AutoHashMap(ValidatorIndex, StoredSignature); + + const InnerHashMap = std.AutoHashMap(attestation.AttestationData, InnerMap); + + inner: InnerHashMap, + allocator: std.mem.Allocator, + + pub fn init(allocator: std.mem.Allocator) SignaturesMap { + return .{ + .inner = InnerHashMap.init(allocator), + .allocator = allocator, + }; + } + + /// Deinit all inner maps, then the outer map itself. + pub fn deinit(self: *SignaturesMap) void { + var it = self.inner.iterator(); + while (it.next()) |entry| entry.value_ptr.deinit(); + self.inner.deinit(); + } + + /// Look up (or create) the inner map for `att_data` and insert the signature. + pub fn addSignature( + self: *SignaturesMap, + att_data: attestation.AttestationData, + validator_id: utils.ValidatorIndex, + sig: StoredSignature, + ) !void { + const gop = try self.inner.getOrPut(att_data); + if (!gop.found_existing) { + gop.value_ptr.* = InnerMap.init(self.allocator); + } + try gop.value_ptr.put(validator_id, sig); + } + + pub fn getOrPut(self: *SignaturesMap, key: attestation.AttestationData) !InnerHashMap.GetOrPutResult { + return self.inner.getOrPut(key); + } + + pub fn getPtr(self: *const SignaturesMap, key: attestation.AttestationData) ?*InnerMap { + return self.inner.getPtr(key); + } + + pub fn get(self: *const SignaturesMap, key: attestation.AttestationData) ?InnerMap { + return self.inner.get(key); + } + + pub fn fetchRemove(self: *SignaturesMap, key: attestation.AttestationData) ?InnerHashMap.KV { + return self.inner.fetchRemove(key); + } + + /// Remove an entry and deinit its inner map. No-op if key not present. + pub fn removeAndDeinit(self: *SignaturesMap, key: attestation.AttestationData) void { + if (self.inner.fetchRemove(key)) |kv| { + var inner = kv.value; + inner.deinit(); + } + } + + pub fn put(self: *SignaturesMap, key: attestation.AttestationData, value: InnerMap) !void { + return self.inner.put(key, value); + } + + pub fn iterator(self: *const SignaturesMap) InnerHashMap.Iterator { + return self.inner.iterator(); + } + + pub fn count(self: *const SignaturesMap) InnerHashMap.Size { + return self.inner.count(); + } +}; + +/// Aggregate all individual gossip signatures in an inner map into a single proof. +/// The caller owns the returned proof and must call `deinit` on it. +pub fn AggregateInnerMap( + allocator: Allocator, + inner_map: *const SignaturesMap.InnerMap, + att_data: attestation.AttestationData, + validators: *const validator.Validators, +) !aggregation.AggregatedSignatureProof { + var message_hash: [32]u8 = undefined; + try zeam_utils.hashTreeRoot(attestation.AttestationData, att_data, &message_hash, allocator); + + var sigs: std.ArrayList(xmss.Signature) = .empty; + defer { + for (sigs.items) |*sig| sig.deinit(); + sigs.deinit(allocator); + } + + var pks: std.ArrayList(xmss.PublicKey) = .empty; + defer { + for (pks.items) |*pk| pk.deinit(); + pks.deinit(allocator); + } + + var participants = try attestation.AggregationBits.init(allocator); + var participants_cleanup = true; + errdefer if (participants_cleanup) participants.deinit(); + + const ValidatorEntry = struct { + validator_id: utils.ValidatorIndex, + stored_sig: *const StoredSignature, + }; + var validator_entries: std.ArrayList(ValidatorEntry) = .empty; + defer validator_entries.deinit(allocator); + + var it = inner_map.iterator(); + while (it.next()) |entry| { + try validator_entries.append(allocator, .{ + .validator_id = entry.key_ptr.*, + .stored_sig = entry.value_ptr, + }); + } + + std.mem.sort(ValidatorEntry, validator_entries.items, {}, struct { + fn lessThan(_: void, a: ValidatorEntry, b: ValidatorEntry) bool { + return a.validator_id < b.validator_id; + } + }.lessThan); + + for (validator_entries.items) |ve| { + const validator_idx: usize = @intCast(ve.validator_id); + + var sig = try xmss.Signature.fromBytes(ve.stored_sig.signature[0..]); + errdefer sig.deinit(); + + const val = try validators.get(ve.validator_id); + var pk = try xmss.PublicKey.fromBytes(&val.pubkey); + errdefer pk.deinit(); + + try attestation.aggregationBitsSet(&participants, validator_idx, true); + try sigs.append(allocator, sig); + try pks.append(allocator, pk); + } + + const num_sigs = sigs.items.len; + const sig_handles = try allocator.alloc(*const xmss.HashSigSignature, num_sigs); + defer allocator.free(sig_handles); + const pk_handles = try allocator.alloc(*const xmss.HashSigPublicKey, num_sigs); + defer allocator.free(pk_handles); + + for (sigs.items, 0..) |*sig, i| sig_handles[i] = sig.handle; + for (pks.items, 0..) |*pk, i| pk_handles[i] = pk.handle; + + var proof = try aggregation.AggregatedSignatureProof.init(allocator); + errdefer proof.deinit(); + + try aggregation.AggregatedSignatureProof.aggregate( + participants, + pk_handles, + sig_handles, + &message_hash, + @intCast(att_data.slot), + &proof, + ); + participants_cleanup = false; + + return proof; +} /// Stored aggregated payload entry pub const StoredAggregatedPayload = struct { @@ -57,8 +212,8 @@ pub const StoredAggregatedPayload = struct { /// List of aggregated payloads for a single key pub const AggregatedPayloadsList = std.ArrayList(StoredAggregatedPayload); -/// Map type for aggregated payloads: SignatureKey -> list of AggregatedSignatureProof -pub const AggregatedPayloadsMap = std.AutoHashMap(SignatureKey, AggregatedPayloadsList); +/// Map type for aggregated payloads: AttestationData -> list of AggregatedSignatureProof. +pub const AggregatedPayloadsMap = std.AutoHashMap(attestation.AttestationData, AggregatedPayloadsList); // Types pub const BeamBlockBody = struct { @@ -303,338 +458,6 @@ pub fn createBlockSignatures(allocator: Allocator, num_aggregated_attestations: }; } -pub const AggregatedAttestationsResult = struct { - attestations: AggregatedAttestations, - attestation_signatures: AttestationSignatures, - allocator: Allocator, - - const Self = @This(); - - pub fn init(allocator: Allocator) !Self { - var attestations_list = try AggregatedAttestations.init(allocator); - errdefer attestations_list.deinit(); - - var signatures_list = try AttestationSignatures.init(allocator); - errdefer signatures_list.deinit(); - - return .{ - .attestations = attestations_list, - .attestation_signatures = signatures_list, - .allocator = allocator, - }; - } - - /// Compute aggregated signatures using three-phase algorithm: - /// Phase 1: Collect individual signatures from signatures_map (chain: gossip_signatures) - /// Phase 2: Fallback to aggregated_payloads using greedy set-cover (if provided) - /// Phase 3: Remove signatures which are already coverd by stored prrofs and aggregate remaining signatures - pub fn computeAggregatedSignatures( - self: *Self, - attestations_list: []const Attestation, - validators: *const Validators, - signatures_map: *const SignaturesMap, - aggregated_payloads: ?*const AggregatedPayloadsMap, - ) !void { - const allocator = self.allocator; - - // Group attestations by data root using bitsets for validator tracking - const AttestationGroup = struct { - data: attestation.AttestationData, - data_root: Root, - validator_bits: std.DynamicBitSet, - }; - - var groups: std.ArrayList(AttestationGroup) = .empty; - defer { - for (groups.items) |*group| { - group.validator_bits.deinit(); - } - groups.deinit(allocator); - } - - var root_indices = std.AutoHashMap(Root, usize).init(allocator); - defer root_indices.deinit(); - - // Group attestations by data root - for (attestations_list) |att| { - const data_root = try att.data.sszRoot(allocator); - const vid: usize = @intCast(att.validator_id); - if (root_indices.get(data_root)) |group_index| { - var bits = &groups.items[group_index].validator_bits; - if (vid >= bits.capacity()) { - try bits.resize(vid + 1, false); - } - bits.set(vid); - } else { - var new_bits = try std.DynamicBitSet.initEmpty(allocator, vid + 1); - new_bits.set(vid); - try groups.append(allocator, .{ - .data = att.data, - .data_root = data_root, - .validator_bits = new_bits, - }); - try root_indices.put(data_root, groups.items.len - 1); - } - } - - // Process each group - for (groups.items) |*group| { - const data_root = group.data_root; - const epoch: u64 = group.data.slot; - var message_hash: [32]u8 = undefined; - try zeam_utils.hashTreeRoot(attestation.AttestationData, group.data, &message_hash, allocator); - - // Phase 1: Collect signatures from signatures_map - const max_validator = group.validator_bits.capacity(); - - var sigmap_sigs: std.ArrayList(xmss.Signature) = .empty; - defer { - for (sigmap_sigs.items) |*sig| { - sig.deinit(); - } - sigmap_sigs.deinit(allocator); - } - - var sigmap_pks: std.ArrayList(xmss.PublicKey) = .empty; - defer { - for (sigmap_pks.items) |*pk| { - pk.deinit(); - } - sigmap_pks.deinit(allocator); - } - - // Map from validator_id to index in signatures_map arrays - // Used to remove signatures from sigmap_sigs while aggregating which are already covered by stored proofs - var vid_to_sigmap_idx = try allocator.alloc(?usize, max_validator); - defer allocator.free(vid_to_sigmap_idx); - @memset(vid_to_sigmap_idx, null); - - // Bitsets for tracking validator states - var remaining = try std.DynamicBitSet.initEmpty(allocator, max_validator); - defer remaining.deinit(); - - var sigmap_available = try std.DynamicBitSet.initEmpty(allocator, max_validator); - defer sigmap_available.deinit(); - - // Track validators covered by stored proofs (to avoid redundancy with signatures_map) - var covered_by_stored = try std.DynamicBitSet.initEmpty(allocator, max_validator); - defer covered_by_stored.deinit(); - - // Attempt to collect each validator's signature from signatures_map - var validator_it = group.validator_bits.iterator(.{}); - while (validator_it.next()) |validator_id| { - const vid: ValidatorIndex = @intCast(validator_id); - if (signatures_map.get(.{ .validator_id = vid, .data_root = data_root })) |sig_entry| { - // Check if it's not a zero signature - if (!std.mem.eql(u8, &sig_entry.signature, &ZERO_SIGBYTES)) { - // Deserialize signature - var sig = xmss.Signature.fromBytes(&sig_entry.signature) catch { - remaining.set(validator_id); - continue; - }; - errdefer sig.deinit(); - - // Get public key from validator - if (validator_id >= validators.len()) { - sig.deinit(); - remaining.set(validator_id); - continue; - } - - const val = validators.get(validator_id) catch { - sig.deinit(); - remaining.set(validator_id); - continue; - }; - const pk = xmss.PublicKey.fromBytes(&val.pubkey) catch { - sig.deinit(); - remaining.set(validator_id); - continue; - }; - - vid_to_sigmap_idx[validator_id] = sigmap_sigs.items.len; - try sigmap_sigs.append(allocator, sig); - try sigmap_pks.append(allocator, pk); - sigmap_available.set(validator_id); - } else { - remaining.set(validator_id); - } - } else { - remaining.set(validator_id); - } - } - - // Phase 2: Fallback to aggregated_payloads using greedy set-cover - if (aggregated_payloads) |agg_payloads| { - // Temporary bitset for computing coverage - var proof_bits = try std.DynamicBitSet.initEmpty(allocator, max_validator); - defer proof_bits.deinit(); - - while (remaining.count() > 0) { - // Pick any remaining validator to look up proofs - const target_id = remaining.findFirstSet() orelse break; - const vid: ValidatorIndex = @intCast(target_id); - - // Remove the target_id from remaining if not covered by stored proofs - const candidates = agg_payloads.get(.{ .validator_id = vid, .data_root = data_root }) orelse { - remaining.unset(target_id); - continue; - }; - - if (candidates.items.len == 0) { - remaining.unset(target_id); - continue; - } - - // Find the proof covering the most remaining validators (greedy set-cover) - var best_proof: ?*const aggregation.AggregatedSignatureProof = null; - var max_coverage: usize = 0; - - for (candidates.items) |*stored| { - const proof = &stored.proof; - const max_participants = proof.participants.len(); - - // Reset and populate proof_bits from participants - proof_bits.setRangeValue(.{ .start = 0, .end = proof_bits.capacity() }, false); - if (max_participants > proof_bits.capacity()) { - try proof_bits.resize(max_participants, false); - } - - var coverage: usize = 0; - - for (0..max_participants) |i| { - if (proof.participants.get(i) catch false) { - // Count coverage of validators still in remaining (not yet covered by stored proofs) - if (i < remaining.capacity() and remaining.isSet(i)) { - proof_bits.set(i); - coverage += 1; - } - } - } - - if (coverage == 0) { - continue; - } - - if (coverage > max_coverage) { - max_coverage = coverage; - best_proof = proof; - } - } - - if (best_proof == null or max_coverage == 0) { - remaining.unset(target_id); - continue; - } - - // Clone and add the proof - var cloned_proof: aggregation.AggregatedSignatureProof = undefined; - try utils.sszClone(allocator, aggregation.AggregatedSignatureProof, best_proof.?.*, &cloned_proof); - errdefer cloned_proof.deinit(); - - // Create aggregated attestation matching the proof's participants - // and update tracking bitsets in a single pass - var att_bits = try attestation.AggregationBits.init(allocator); - errdefer att_bits.deinit(); - - for (0..cloned_proof.participants.len()) |i| { - if (cloned_proof.participants.get(i) catch false) { - try attestation.aggregationBitsSet(&att_bits, i, true); - if (i < remaining.capacity()) { - remaining.unset(i); - } - // Track ALL validators covered by stored proofs to remove from signatures_map later - if (i >= covered_by_stored.capacity()) { - try covered_by_stored.resize(i + 1, false); - } - covered_by_stored.set(i); - } - } - - try self.attestations.append(.{ .aggregation_bits = att_bits, .data = group.data }); - try self.attestation_signatures.append(cloned_proof); - } - } - - // Finally, aggregate signatures_map for validators NOT covered by stored proofs - // This avoids redundancy: if a validator is in a stored proof, don't include them in signatures_map aggregation - var usable_count: usize = 0; - var git = sigmap_available.iterator(.{}); - while (git.next()) |vid| { - if (vid >= covered_by_stored.capacity() or !covered_by_stored.isSet(vid)) { - usable_count += 1; - } - } - - if (usable_count > 0) { - var participants = try attestation.AggregationBits.init(allocator); - var participants_cleanup = true; - errdefer if (participants_cleanup) participants.deinit(); - - var pk_handles = try allocator.alloc(*const xmss.HashSigPublicKey, usable_count); - defer allocator.free(pk_handles); - var sig_handles = try allocator.alloc(*const xmss.HashSigSignature, usable_count); - defer allocator.free(sig_handles); - - // Iterate sigmap_available in order, skipping validators already in stored proofs - var handle_idx: usize = 0; - var git2 = sigmap_available.iterator(.{}); - while (git2.next()) |vid| { - // Skip if already covered by a stored proof - if (vid < covered_by_stored.capacity() and covered_by_stored.isSet(vid)) continue; - - try attestation.aggregationBitsSet(&participants, vid, true); - const sigmap_idx = vid_to_sigmap_idx[vid].?; - pk_handles[handle_idx] = sigmap_pks.items[sigmap_idx].handle; - sig_handles[handle_idx] = sigmap_sigs.items[sigmap_idx].handle; - handle_idx += 1; - } - - var proof = try aggregation.AggregatedSignatureProof.init(allocator); - errdefer proof.deinit(); - - try aggregation.AggregatedSignatureProof.aggregate( - participants, - pk_handles[0..handle_idx], - sig_handles[0..handle_idx], - &message_hash, - epoch, - &proof, - ); - participants_cleanup = false; // proof now owns participants buffer - - // Create aggregated attestation using proof's participants (which now owns the bits) - // We need to clone it since we're moving it into the attestation - var att_bits = try attestation.AggregationBits.init(allocator); - errdefer att_bits.deinit(); - - // Clone from proof.participants - const proof_participants_len = proof.participants.len(); - for (0..proof_participants_len) |i| { - if (proof.participants.get(i) catch false) { - try attestation.aggregationBitsSet(&att_bits, i, true); - } - } - - try self.attestations.append(.{ .aggregation_bits = att_bits, .data = group.data }); - try self.attestation_signatures.append(proof); - } - } - } - - pub fn deinit(self: *Self) void { - for (self.attestations.slice()) |*att| { - att.deinit(); - } - self.attestations.deinit(); - - for (self.attestation_signatures.slice()) |*sig_group| { - sig_group.deinit(); - } - self.attestation_signatures.deinit(); - } -}; - pub const BlockByRootRequest = struct { roots: ssz.utils.List(utils.Root, params.MAX_REQUEST_BLOCKS), diff --git a/pkgs/types/src/block_signatures_testing.zig b/pkgs/types/src/block_signatures_testing.zig deleted file mode 100644 index 7cb4758f..00000000 --- a/pkgs/types/src/block_signatures_testing.zig +++ /dev/null @@ -1,1118 +0,0 @@ -const std = @import("std"); -const ssz = @import("ssz"); - -const params = @import("@zeam/params"); -const xmss = @import("@zeam/xmss"); -const zeam_utils = @import("@zeam/utils"); - -const aggregation = @import("./aggregation.zig"); -const attestation = @import("./attestation.zig"); -const mini_3sf = @import("./mini_3sf.zig"); -const state = @import("./state.zig"); -const utils = @import("./utils.zig"); -const validator = @import("./validator.zig"); - -const block = @import("./block.zig"); -const Allocator = std.mem.Allocator; -const SignaturesMap = block.SignaturesMap; -const AggregatedPayloadsMap = block.AggregatedPayloadsMap; -const ValidatorIndex = utils.ValidatorIndex; -const Root = utils.Root; -const ZERO_HASH = utils.ZERO_HASH; - -const SignatureKey = block.SignatureKey; -const AggregatedAttestationsResult = block.AggregatedAttestationsResult; -const AggregatedPayloadsList = block.AggregatedPayloadsList; - -// ============================================================================ -// Test helpers for computeAggregatedSignatures -// ============================================================================ - -const keymanager = @import("@zeam/key-manager"); - -const TestContext = struct { - allocator: std.mem.Allocator, - key_manager: keymanager.KeyManager, - validators: validator.Validators, - data_root: Root, - attestation_data: attestation.AttestationData, - - pub fn init(allocator: std.mem.Allocator, num_validators: usize) !TestContext { - var key_manager = try keymanager.getTestKeyManager(allocator, num_validators, 10); - errdefer key_manager.deinit(); - - // Create validators with proper pubkeys - var validators_list = try validator.Validators.init(allocator); - errdefer validators_list.deinit(); - - for (0..num_validators) |i| { - var pubkey: utils.Bytes52 = undefined; - _ = try key_manager.getPublicKeyBytes(@intCast(i), &pubkey); - try validators_list.append(.{ - .pubkey = pubkey, - .index = @intCast(i), - }); - } - - // Create common attestation data - const att_data = attestation.AttestationData{ - .slot = 5, - .head = .{ .root = [_]u8{1} ** 32, .slot = 5 }, - .target = .{ .root = [_]u8{1} ** 32, .slot = 5 }, - .source = .{ .root = ZERO_HASH, .slot = 0 }, - }; - - const data_root = try att_data.sszRoot(allocator); - - return TestContext{ - .allocator = allocator, - .key_manager = key_manager, - .validators = validators_list, - .data_root = data_root, - .attestation_data = att_data, - }; - } - - pub fn deinit(self: *TestContext) void { - self.validators.deinit(); - self.key_manager.deinit(); - } - - /// Create an attestation for a given validator - pub fn createAttestation(self: *const TestContext, validator_id: ValidatorIndex) attestation.Attestation { - return attestation.Attestation{ - .validator_id = validator_id, - .data = self.attestation_data, - }; - } - - /// Create attestation with custom data (for different groups) - pub fn createAttestationWithData(self: *const TestContext, validator_id: ValidatorIndex, data: attestation.AttestationData) attestation.Attestation { - _ = self; - return attestation.Attestation{ - .validator_id = validator_id, - .data = data, - }; - } - - /// Sign an attestation and add to signatures map - pub fn addToSignatureMap( - self: *TestContext, - signatures_map: *SignaturesMap, - validator_id: ValidatorIndex, - ) !void { - const att = self.createAttestation(validator_id); - const sig_bytes = try self.key_manager.signAttestation(&att, self.allocator); - try signatures_map.put( - .{ .validator_id = validator_id, .data_root = self.data_root }, - .{ .slot = self.attestation_data.slot, .signature = sig_bytes }, - ); - } - - /// Create an aggregated proof covering specified validators - pub fn createAggregatedProof( - self: *TestContext, - validator_ids: []const ValidatorIndex, - ) !aggregation.AggregatedSignatureProof { - // Create attestations and collect signatures - var sigs = std.ArrayList(xmss.Signature).init(self.allocator); - defer { - for (sigs.items) |*sig| sig.deinit(); - sigs.deinit(); - } - - var pks = std.ArrayList(xmss.PublicKey).init(self.allocator); - defer { - for (pks.items) |*pk| pk.deinit(); - pks.deinit(); - } - - for (validator_ids) |vid| { - const att = self.createAttestation(vid); - const sig_bytes = try self.key_manager.signAttestation(&att, self.allocator); - var sig = try xmss.Signature.fromBytes(&sig_bytes); - errdefer sig.deinit(); - - const val = try self.validators.get(@intCast(vid)); - var pk = try xmss.PublicKey.fromBytes(&val.pubkey); - errdefer pk.deinit(); - - try sigs.append(sig); - try pks.append(pk); - } - - // Build handle arrays - var pk_handles = try self.allocator.alloc(*const xmss.HashSigPublicKey, pks.items.len); - defer self.allocator.free(pk_handles); - var sig_handles = try self.allocator.alloc(*const xmss.HashSigSignature, sigs.items.len); - defer self.allocator.free(sig_handles); - - for (pks.items, 0..) |*pk, i| { - pk_handles[i] = pk.handle; - } - for (sigs.items, 0..) |*sig, i| { - sig_handles[i] = sig.handle; - } - - // Build participants bitset - var participants = try attestation.AggregationBits.init(self.allocator); - errdefer participants.deinit(); - for (validator_ids) |vid| { - try attestation.aggregationBitsSet(&participants, @intCast(vid), true); - } - - // Compute message hash - var message_hash: [32]u8 = undefined; - try zeam_utils.hashTreeRoot(attestation.AttestationData, self.attestation_data, &message_hash, self.allocator); - - // Aggregate - var proof = try aggregation.AggregatedSignatureProof.init(self.allocator); - errdefer proof.deinit(); - - try aggregation.AggregatedSignatureProof.aggregate( - participants, - pk_handles, - sig_handles, - &message_hash, - self.attestation_data.slot, - &proof, - ); - - return proof; - } - - /// Add an aggregated proof to the payloads map for a specific validator - pub fn addAggregatedPayload( - self: *TestContext, - payloads_map: *AggregatedPayloadsMap, - lookup_validator_id: ValidatorIndex, - proof: aggregation.AggregatedSignatureProof, - ) !void { - const key = SignatureKey{ .validator_id = lookup_validator_id, .data_root = self.data_root }; - const gop = try payloads_map.getOrPut(key); - if (!gop.found_existing) { - gop.value_ptr.* = AggregatedPayloadsList.init(self.allocator); - } - try gop.value_ptr.append(.{ - .slot = self.attestation_data.slot, - .proof = proof, - }); - } - - /// Helper to check if a bitset contains exactly the specified validators - pub fn checkParticipants(bits: *const attestation.AggregationBits, expected_validators: []const ValidatorIndex) !bool { - var count: usize = 0; - for (0..bits.len()) |i| { - if (try bits.get(i)) { - count += 1; - var found = false; - for (expected_validators) |vid| { - if (i == vid) { - found = true; - break; - } - } - if (!found) return false; - } - } - return count == expected_validators.len; - } -}; - -fn deinitSignaturesMap(map: *SignaturesMap) void { - map.deinit(); -} - -fn deinitPayloadsMap(map: *AggregatedPayloadsMap) void { - var it = map.valueIterator(); - while (it.next()) |list| { - for (list.items) |*item| { - item.proof.deinit(); - } - list.deinit(); - } - map.deinit(); -} - -// ============================================================================ -// Test 1: All 4 signatures in signatures_map (pure signatures_map) -// ============================================================================ -test "computeAggregatedSignatures: all 4 in signatures_map" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create attestations for all 4 validators - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - }; - - // Add all 4 signatures to signatures_map - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 0); - try ctx.addToSignatureMap(&signatures_map, 1); - try ctx.addToSignatureMap(&signatures_map, 2); - try ctx.addToSignatureMap(&signatures_map, 3); - - // No aggregated payloads - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 1 aggregated attestation covering all 4 validators - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestation_signatures.len()); - - const att_bits = &(try agg_ctx.attestations.get(0)).aggregation_bits; - try std.testing.expect(try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 0, 1, 2, 3 })); -} - -// ============================================================================ -// Test 2: 2 in signatures_map, 2 in aggregated_proof (clean split) -// ============================================================================ -test "computeAggregatedSignatures: 2 signatures_map, 2 in aggregated proof" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create attestations for all 4 validators - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - }; - - // Add signatures for validators 0, 1 only - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 0); - try ctx.addToSignatureMap(&signatures_map, 1); - - // Create aggregated proof for validators 2, 3 - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_2_3 = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 2, 3 }); - // Add to both validator 2 and 3's lookup - try ctx.addAggregatedPayload(&payloads_map, 2, proof_2_3); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 2 aggregated attestations - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestation_signatures.len()); - - // Verify one covers 2,3 and one covers 0,1 - var found_0_1 = false; - var found_2_3 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 0, 1 })) { - found_0_1 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 2, 3 })) { - found_2_3 = true; - } - } - - try std.testing.expect(found_0_1); - try std.testing.expect(found_2_3); -} - -// ============================================================================ -// Test 3: 2 in signatures_map, all 4 in aggregated_proof (full overlap - no redundancy) -// When stored proof covers ALL validators, signatures_map aggregation is skipped -// ============================================================================ -test "computeAggregatedSignatures: full overlap uses stored only" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create attestations for all 4 validators - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - }; - - // Add signatures for validators 0, 1 only - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 0); - try ctx.addToSignatureMap(&signatures_map, 1); - - // Create aggregated proof for ALL 4 validators (fully covers 0,1) - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_all = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 0, 1, 2, 3 }); - try ctx.addAggregatedPayload(&payloads_map, 2, proof_all); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have only 1 aggregated attestation: - // - Stored proof covering {0,1,2,3} - // - signatures_map {0,1} is NOT included because all validators are covered by stored proof - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestation_signatures.len()); - - const att_bits = &(try agg_ctx.attestations.get(0)).aggregation_bits; - try std.testing.expect(try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 0, 1, 2, 3 })); -} - -// ============================================================================ -// Test 4: Greedy set-cover with competing proofs -// ============================================================================ -test "computeAggregatedSignatures: greedy set-cover" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create attestations for all 4 validators - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - }; - - // Add signature only for validator 0 - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 0); - - // Create competing aggregated proofs: - // Proof A: covers 1,2,3 (optimal) - // Proof B: covers 1,2 (suboptimal) - // Proof C: covers 2,3 (suboptimal) - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_a = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 1, 2, 3 }); - const proof_b = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 1, 2 }); - - // Add proof A and B for validator 1 lookup - try ctx.addAggregatedPayload(&payloads_map, 1, proof_a); - try ctx.addAggregatedPayload(&payloads_map, 1, proof_b); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 2 aggregated attestations: - // 1. signatures_map for validator 0 - // 2. Aggregated proof A for validators 1,2,3 - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestation_signatures.len()); - - // Verify one covers 0 and one covers 1,2,3 - var found_0 = false; - var found_1_2_3 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{0})) { - found_0 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 1, 2, 3 })) { - found_1_2_3 = true; - } - } - - try std.testing.expect(found_0); - try std.testing.expect(found_1_2_3); -} - -// ============================================================================ -// Test 5: Partial signatures_map overlap with stored proof (maximize coverage) -// signatures_map {1,2} + Stored {2,3,4} = Both included for maximum coverage {1,2,3,4} -// ============================================================================ -test "computeAggregatedSignatures: partial signatures_map overlap maximizes coverage" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 5); - defer ctx.deinit(); - - // Create attestations for validators 1,2,3,4 - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - ctx.createAttestation(4), - }; - - // Add signatures_map for validators 1, 2 only - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 1); - try ctx.addToSignatureMap(&signatures_map, 2); - - // Create aggregated proof for validators 2, 3, 4 (overlaps with signatures_map on 2) - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_2_3_4 = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 2, 3, 4 }); - try ctx.addAggregatedPayload(&payloads_map, 3, proof_2_3_4); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have 2 aggregated attestations: - // 1. Stored proof covering {2,3,4} - // 2. signatures_map aggregation covering {1} only (validator 2 excluded - already in stored proof) - // Together they cover {1,2,3,4} without redundancy - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestation_signatures.len()); - - // Verify both aggregations exist - var found_1 = false; - var found_2_3_4 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{1})) { - found_1 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 2, 3, 4 })) { - found_2_3_4 = true; - } - } - - try std.testing.expect(found_1); - try std.testing.expect(found_2_3_4); -} - -// ============================================================================ -// Test 6: Empty attestations list -// ============================================================================ -test "computeAggregatedSignatures: empty attestations" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - var attestations_list = [_]attestation.Attestation{}; - - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have no attestations - try std.testing.expectEqual(@as(usize, 0), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 0), agg_ctx.attestation_signatures.len()); -} - -// ============================================================================ -// Test 7: No signatures available -// ============================================================================ -test "computeAggregatedSignatures: no signatures available" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create attestations for all 4 validators - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - }; - - // No signatures_map signatures - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - // No aggregated payloads - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have no attestations (all validators uncovered) - try std.testing.expectEqual(@as(usize, 0), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 0), agg_ctx.attestation_signatures.len()); -} - -// ============================================================================ -// Test 8: Multiple data roots (separate groups) -// ============================================================================ -test "computeAggregatedSignatures: multiple data roots" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 4); - defer ctx.deinit(); - - // Create second attestation data with different slot - const att_data_2 = attestation.AttestationData{ - .slot = 10, - .head = .{ .root = [_]u8{2} ** 32, .slot = 10 }, - .target = .{ .root = [_]u8{2} ** 32, .slot = 10 }, - .source = .{ .root = ZERO_HASH, .slot = 0 }, - }; - const data_root_2 = try att_data_2.sszRoot(allocator); - - // Create attestations: 0,1 with data_root_1, 2,3 with data_root_2 - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), // data_root_1 - ctx.createAttestation(1), // data_root_1 - ctx.createAttestationWithData(2, att_data_2), // data_root_2 - ctx.createAttestationWithData(3, att_data_2), // data_root_2 - }; - - // Add signatures_map signatures for all - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - // Signatures for group 1 (data_root_1) - try ctx.addToSignatureMap(&signatures_map, 0); - try ctx.addToSignatureMap(&signatures_map, 1); - - // Signatures for group 2 (data_root_2) - need to sign with different data - const att_2 = attestations_list[2]; - const sig_bytes_2 = try ctx.key_manager.signAttestation(&att_2, allocator); - try signatures_map.put( - .{ .validator_id = 2, .data_root = data_root_2 }, - .{ .slot = att_data_2.slot, .signature = sig_bytes_2 }, - ); - - const att_3 = attestations_list[3]; - const sig_bytes_3 = try ctx.key_manager.signAttestation(&att_3, allocator); - try signatures_map.put( - .{ .validator_id = 3, .data_root = data_root_2 }, - .{ .slot = att_data_2.slot, .signature = sig_bytes_3 }, - ); - - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 2 aggregated attestations (one per data root) - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestation_signatures.len()); - - // Verify one covers 0,1 and one covers 2,3 - var found_0_1 = false; - var found_2_3 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 0, 1 })) { - found_0_1 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 2, 3 })) { - found_2_3 = true; - } - } - - try std.testing.expect(found_0_1); - try std.testing.expect(found_2_3); -} - -// ============================================================================ -// Test 9: Single validator attestation -// ============================================================================ -test "computeAggregatedSignatures: single validator" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 1); - defer ctx.deinit(); - - // Create attestation for single validator - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(0), - }; - - // Add signatures_map signature - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 0); - - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 1 aggregated attestation with 1 validator - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestation_signatures.len()); - - const att_bits = &(try agg_ctx.attestations.get(0)).aggregation_bits; - try std.testing.expect(try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{0})); -} - -// ============================================================================ -// Test 10: Complex scenario with 3 attestation_data types -// - Group 1: All validators have signatures_map signatures (pure signatures_map) -// - Group 2: All validators covered by aggregated_payload only (pure stored) -// - Group 3: Overlap - some signatures_map + stored proof covering some signatures_map validators -// ============================================================================ -test "computeAggregatedSignatures: complex 3 groups" { - const allocator = std.testing.allocator; - - // Need 10 validators for this test - var ctx = try TestContext.init(allocator, 10); - defer ctx.deinit(); - - // Create 3 different attestation data types - const att_data_1 = ctx.attestation_data; // slot 5 (uses ctx.data_root for signatures_map) - - const att_data_2 = attestation.AttestationData{ - .slot = 10, - .head = .{ .root = [_]u8{2} ** 32, .slot = 10 }, - .target = .{ .root = [_]u8{2} ** 32, .slot = 10 }, - .source = .{ .root = ZERO_HASH, .slot = 0 }, - }; - const data_root_2 = try att_data_2.sszRoot(allocator); - - const att_data_3 = attestation.AttestationData{ - .slot = 15, - .head = .{ .root = [_]u8{3} ** 32, .slot = 15 }, - .target = .{ .root = [_]u8{3} ** 32, .slot = 15 }, - .source = .{ .root = ZERO_HASH, .slot = 0 }, - }; - const data_root_3 = try att_data_3.sszRoot(allocator); - - // Create attestations for all groups: - // Group 1 (data_root_1): validators 0,1,2 - pure signatures_map - // Group 2 (data_root_2): validators 3,4,5 - pure stored - // Group 3 (data_root_3): validators 6,7,8,9 - overlap (signatures_map 6,7 + stored 7,8,9) - var attestations_list = [_]attestation.Attestation{ - // Group 1 - ctx.createAttestationWithData(0, att_data_1), - ctx.createAttestationWithData(1, att_data_1), - ctx.createAttestationWithData(2, att_data_1), - // Group 2 - ctx.createAttestationWithData(3, att_data_2), - ctx.createAttestationWithData(4, att_data_2), - ctx.createAttestationWithData(5, att_data_2), - // Group 3 - ctx.createAttestationWithData(6, att_data_3), - ctx.createAttestationWithData(7, att_data_3), - ctx.createAttestationWithData(8, att_data_3), - ctx.createAttestationWithData(9, att_data_3), - }; - - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - // Group 1: Add signatures_map signatures for validators 0,1,2 - try ctx.addToSignatureMap(&signatures_map, 0); - try ctx.addToSignatureMap(&signatures_map, 1); - try ctx.addToSignatureMap(&signatures_map, 2); - - // Group 2: No signatures_map signatures (all from stored) - - // Group 3: Add signatures_map signatures for validators 6,7 only - const att_6 = attestations_list[6]; - const sig_bytes_6 = try ctx.key_manager.signAttestation(&att_6, allocator); - try signatures_map.put( - .{ .validator_id = 6, .data_root = data_root_3 }, - .{ .slot = att_data_3.slot, .signature = sig_bytes_6 }, - ); - - const att_7 = attestations_list[7]; - const sig_bytes_7 = try ctx.key_manager.signAttestation(&att_7, allocator); - try signatures_map.put( - .{ .validator_id = 7, .data_root = data_root_3 }, - .{ .slot = att_data_3.slot, .signature = sig_bytes_7 }, - ); - - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - // Group 2: Create aggregated proof for validators 3,4,5 - { - // Need to create proof with att_data_2 - var sigs = std.ArrayList(xmss.Signature).init(allocator); - defer { - for (sigs.items) |*sig| sig.deinit(); - sigs.deinit(); - } - var pks = std.ArrayList(xmss.PublicKey).init(allocator); - defer { - for (pks.items) |*pk| pk.deinit(); - pks.deinit(); - } - - for ([_]ValidatorIndex{ 3, 4, 5 }) |vid| { - const att = attestations_list[vid]; - const sig_bytes = try ctx.key_manager.signAttestation(&att, allocator); - var sig = try xmss.Signature.fromBytes(&sig_bytes); - errdefer sig.deinit(); - const val = try ctx.validators.get(@intCast(vid)); - var pk = try xmss.PublicKey.fromBytes(&val.pubkey); - errdefer pk.deinit(); - try sigs.append(sig); - try pks.append(pk); - } - - var pk_handles = try allocator.alloc(*const xmss.HashSigPublicKey, 3); - defer allocator.free(pk_handles); - var sig_handles = try allocator.alloc(*const xmss.HashSigSignature, 3); - defer allocator.free(sig_handles); - - for (pks.items, 0..) |*pk, i| pk_handles[i] = pk.handle; - for (sigs.items, 0..) |*sig, i| sig_handles[i] = sig.handle; - - var participants = try attestation.AggregationBits.init(allocator); - errdefer participants.deinit(); - for ([_]ValidatorIndex{ 3, 4, 5 }) |vid| { - try attestation.aggregationBitsSet(&participants, @intCast(vid), true); - } - - var message_hash: [32]u8 = undefined; - try zeam_utils.hashTreeRoot(attestation.AttestationData, att_data_2, &message_hash, allocator); - - var proof = try aggregation.AggregatedSignatureProof.init(allocator); - errdefer proof.deinit(); - - try aggregation.AggregatedSignatureProof.aggregate( - participants, - pk_handles, - sig_handles, - &message_hash, - att_data_2.slot, - &proof, - ); - - // Add to payloads_map for validator 3 - const key = SignatureKey{ .validator_id = 3, .data_root = data_root_2 }; - const gop = try payloads_map.getOrPut(key); - if (!gop.found_existing) { - gop.value_ptr.* = AggregatedPayloadsList.init(allocator); - } - try gop.value_ptr.append(.{ .slot = att_data_2.slot, .proof = proof }); - } - - // Group 3: Create aggregated proof for validators 7,8,9 (overlaps with signatures_map on 7) - { - var sigs = std.ArrayList(xmss.Signature).init(allocator); - defer { - for (sigs.items) |*sig| sig.deinit(); - sigs.deinit(); - } - var pks = std.ArrayList(xmss.PublicKey).init(allocator); - defer { - for (pks.items) |*pk| pk.deinit(); - pks.deinit(); - } - - for ([_]ValidatorIndex{ 7, 8, 9 }) |vid| { - const att = attestations_list[vid]; - const sig_bytes = try ctx.key_manager.signAttestation(&att, allocator); - var sig = try xmss.Signature.fromBytes(&sig_bytes); - errdefer sig.deinit(); - const val = try ctx.validators.get(@intCast(vid)); - var pk = try xmss.PublicKey.fromBytes(&val.pubkey); - errdefer pk.deinit(); - try sigs.append(sig); - try pks.append(pk); - } - - var pk_handles = try allocator.alloc(*const xmss.HashSigPublicKey, 3); - defer allocator.free(pk_handles); - var sig_handles = try allocator.alloc(*const xmss.HashSigSignature, 3); - defer allocator.free(sig_handles); - - for (pks.items, 0..) |*pk, i| pk_handles[i] = pk.handle; - for (sigs.items, 0..) |*sig, i| sig_handles[i] = sig.handle; - - var participants = try attestation.AggregationBits.init(allocator); - errdefer participants.deinit(); - for ([_]ValidatorIndex{ 7, 8, 9 }) |vid| { - try attestation.aggregationBitsSet(&participants, @intCast(vid), true); - } - - var message_hash: [32]u8 = undefined; - try zeam_utils.hashTreeRoot(attestation.AttestationData, att_data_3, &message_hash, allocator); - - var proof = try aggregation.AggregatedSignatureProof.init(allocator); - errdefer proof.deinit(); - - try aggregation.AggregatedSignatureProof.aggregate( - participants, - pk_handles, - sig_handles, - &message_hash, - att_data_3.slot, - &proof, - ); - - // Add to payloads_map for validator 8 (one of the remaining signatures_map validators) - const key = SignatureKey{ .validator_id = 8, .data_root = data_root_3 }; - const gop = try payloads_map.getOrPut(key); - if (!gop.found_existing) { - gop.value_ptr.* = AggregatedPayloadsList.init(allocator); - } - try gop.value_ptr.append(.{ .slot = att_data_3.slot, .proof = proof }); - } - - // Execute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Expected results: - // - Group 1: 1 attestation from signatures_map {0,1,2} - // - Group 2: 1 attestation from stored {3,4,5} - // - Group 3: 2 attestations - stored {7,8,9} + signatures_map {6} (7 excluded from signatures_map) - // Total: 4 attestations - try std.testing.expectEqual(@as(usize, 4), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 4), agg_ctx.attestation_signatures.len()); - - // Verify each group - var found_0_1_2 = false; - var found_3_4_5 = false; - var found_7_8_9 = false; - var found_6 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 0, 1, 2 })) { - found_0_1_2 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 3, 4, 5 })) { - found_3_4_5 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 7, 8, 9 })) { - found_7_8_9 = true; - } - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{6})) { - found_6 = true; - } - } - - try std.testing.expect(found_0_1_2); // Group 1: pure signatures_map - try std.testing.expect(found_3_4_5); // Group 2: pure stored - try std.testing.expect(found_7_8_9); // Group 3: stored proof - try std.testing.expect(found_6); // Group 3: remaining signatures_map (7 excluded) -} - -// ============================================================================ -// Test 11: Validator without signature is excluded -// signatures_map {1} + aggregated_payload {2,3} = attestations {1} + {2,3}, validator 4 excluded -// ============================================================================ -test "computeAggregatedSignatures: validator without signature excluded" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 5); - defer ctx.deinit(); - - // Create attestations for validators 1, 2, 3, 4 - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(1), - ctx.createAttestation(2), - ctx.createAttestation(3), - ctx.createAttestation(4), - }; - - // Add signature only for validator 1 to signatures_map - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - try ctx.addToSignatureMap(&signatures_map, 1); - - // Create aggregated proof for validators 2, 3 only - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_2_3 = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 2, 3 }); - try ctx.addAggregatedPayload(&payloads_map, 2, proof_2_3); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 2 aggregated attestations: - // 1. signatures_map for validator 1 - // 2. Aggregated proof for validators 2, 3 - // Validator 4 should be excluded (no signature available) - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 2), agg_ctx.attestation_signatures.len()); - - // Verify one covers {1} and one covers {2, 3} - var found_1 = false; - var found_2_3 = false; - - for (0..agg_ctx.attestations.len()) |i| { - const att_bits = &(try agg_ctx.attestations.get(i)).aggregation_bits; - - // Check for validator 1 only - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{1})) { - found_1 = true; - } - // Check for validators 2, 3 - if (try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 2, 3 })) { - found_2_3 = true; - } - - // Verify validator 4 is NOT included in any attestation - // If the bitlist has fewer than 5 elements, validator 4 can't be included - if (att_bits.len() > 4) { - try std.testing.expect(!(try att_bits.get(4))); - } - } - - try std.testing.expect(found_1); - try std.testing.expect(found_2_3); -} - -// ============================================================================ -// Test 12: Single attestation lookup key with all validators in aggregated payload -// Attestations for validators 1,2 nothing in signatures_map, -// aggregated_payload {1,2,3,4} indexed by validator 1 => all bits set -// Validators 3 and 4 are included although not covered by attestations_list -// ============================================================================ -test "computeAggregatedSignatures: empty signatures_map with full aggregated payload" { - const allocator = std.testing.allocator; - - var ctx = try TestContext.init(allocator, 5); - defer ctx.deinit(); - - // Create attestations for validators 1, 2 - var attestations_list = [_]attestation.Attestation{ - ctx.createAttestation(1), - ctx.createAttestation(2), - }; - - // Empty signatures_map - nothing found while iterating - var signatures_map = SignaturesMap.init(allocator); - defer deinitSignaturesMap(&signatures_map); - - // Create aggregated proof for validators 1, 2, 3, 4 indexed by validator 1 - var payloads_map = AggregatedPayloadsMap.init(allocator); - defer deinitPayloadsMap(&payloads_map); - - const proof_1_2_3_4 = try ctx.createAggregatedProof(&[_]ValidatorIndex{ 1, 2, 3, 4 }); - try ctx.addAggregatedPayload(&payloads_map, 1, proof_1_2_3_4); - - // Create aggregation context and compute - var agg_ctx = try AggregatedAttestationsResult.init(allocator); - defer agg_ctx.deinit(); - - try agg_ctx.computeAggregatedSignatures( - &attestations_list, - &ctx.validators, - &signatures_map, - &payloads_map, - ); - - // Should have exactly 1 aggregated attestation covering all 4 validators - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestations.len()); - try std.testing.expectEqual(@as(usize, 1), agg_ctx.attestation_signatures.len()); - - // Verify attestation_bits are set for validators 1, 2, 3, 4 - const att_bits = &(try agg_ctx.attestations.get(0)).aggregation_bits; - try std.testing.expect(try TestContext.checkParticipants(att_bits, &[_]ValidatorIndex{ 1, 2, 3, 4 })); -} diff --git a/pkgs/types/src/lib.zig b/pkgs/types/src/lib.zig index b0b084af..40f8fc4f 100644 --- a/pkgs/types/src/lib.zig +++ b/pkgs/types/src/lib.zig @@ -22,11 +22,10 @@ pub const BeamBlockBody = block.BeamBlockBody; pub const BlockWithAttestation = block.BlockWithAttestation; pub const SignedBlockWithAttestation = block.SignedBlockWithAttestation; pub const AggregatedAttestations = block.AggregatedAttestations; -pub const AggregatedAttestationsResult = block.AggregatedAttestationsResult; pub const AttestationSignatures = block.AttestationSignatures; pub const BlockSignatures = block.BlockSignatures; pub const createBlockSignatures = block.createBlockSignatures; -pub const SignatureKey = block.SignatureKey; +pub const aggregateInnerMap = block.AggregateInnerMap; pub const StoredSignature = block.StoredSignature; pub const SignaturesMap = block.SignaturesMap; pub const StoredAggregatedPayload = block.StoredAggregatedPayload;