From 7c9dcc2cea8a82d7524b0970b607c51df3b9ddbe Mon Sep 17 00:00:00 2001 From: ch4r10t33r Date: Thu, 21 May 2026 19:22:04 +0100 Subject: [PATCH 1/4] metrics: add lean_block_proposal attestation build metrics Register cross-client metrics for block-proposal attestation selection (phase timing, builds, child payloads consumed, attestation data and aggregate counts) and instrument LstarSpec.build_block. Extend the /metrics API fixture with the new metric names. --- .../test_fixtures/api_endpoint.py | 8 + src/lean_spec/forks/lstar/spec.py | 65 +++++++- src/lean_spec/subspecs/metrics/registry.py | 148 ++++++++++++++++++ .../subspecs/metrics/test_registry.py | 74 +++++++++ 4 files changed, 288 insertions(+), 7 deletions(-) create mode 100644 tests/lean_spec/subspecs/metrics/test_registry.py diff --git a/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py index 34a7e36c7..31b70bf24 100644 --- a/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py +++ b/packages/testing/src/consensus_testing/test_fixtures/api_endpoint.py @@ -170,6 +170,14 @@ def _metrics_response(_store: Store, _fixture: "ApiEndpointTest") -> dict[str, A "lean_attestation_validation_time_seconds", "lean_fork_choice_reorgs_total", "lean_fork_choice_reorg_depth", + "lean_attestation_aggregate_coverage_validators", + "lean_attestation_aggregate_coverage_subnets", + "lean_attestation_aggregate_coverage_diff_validators", + "lean_block_proposal_attestation_build_phase_seconds", + "lean_block_proposal_attestation_builds_total", + "lean_block_proposal_child_payloads_consumed_total", + "lean_block_proposal_attestation_data_selected", + "lean_block_proposal_aggregates_selected", "lean_latest_justified_slot", "lean_latest_finalized_slot", "lean_state_transition_time_seconds", diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index a65e0a59a..173891258 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -1,5 +1,7 @@ """Lstar fork — identity and construction facade.""" +import math +import time from collections import defaultdict from collections.abc import Iterable, Sequence, Set as AbstractSet from typing import Any, ClassVar @@ -31,6 +33,7 @@ JUSTIFICATION_LOOKBACK_SLOTS, MAX_ATTESTATIONS_DATA, ) +from lean_spec.subspecs.metrics.registry import registry as metrics from lean_spec.subspecs.observability import ( observe_on_attestation, observe_on_block, @@ -701,8 +704,10 @@ def build_block( ) processed_att_data: set[AttestationData] = set() + child_payloads_consumed = 0 while True: + select_start = time.perf_counter() found_entries = False for att_data, proofs in sorted( @@ -758,6 +763,7 @@ def build_block( found_entries = True selected, _ = TypeOneMultiSignature.select_greedily(proofs) + child_payloads_consumed += len(selected) aggregated_signatures.extend(selected) for proof in selected: aggregated_attestations.append( @@ -767,9 +773,15 @@ def build_block( ) ) + metrics.lean_block_proposal_attestation_build_phase_seconds.labels( + phase="select_payloads", + ).observe(time.perf_counter() - select_start) + if not found_entries: break + stf_start = time.perf_counter() + # Build candidate block and check if justification changed. candidate_block = self.block_class( slot=slot, @@ -793,11 +805,17 @@ def build_block( post_state.latest_justified != current_justified or post_state.latest_finalized.slot != current_finalized_slot ): + metrics.lean_block_proposal_attestation_build_phase_seconds.labels( + phase="stf_simulate", + ).observe(time.perf_counter() - stf_start) current_justified = post_state.latest_justified current_justified_slots = post_state.justified_slots current_finalized_slot = post_state.latest_finalized.slot continue + metrics.lean_block_proposal_attestation_build_phase_seconds.labels( + phase="stf_simulate", + ).observe(time.perf_counter() - stf_start) break # Compact: merge all proofs sharing the same AttestationData into one @@ -806,6 +824,7 @@ def build_block( # During the fixed-point loop above, multiple proofs may have been # selected for the same AttestationData across iterations. Group them # and merge each group into a single recursive proof. + compact_start = time.perf_counter() proof_groups: dict[AttestationData, list[TypeOneMultiSignature]] = {} for att, sig in zip(aggregated_attestations, aggregated_signatures, strict=True): proof_groups.setdefault(att.data, []).append(sig) @@ -843,6 +862,18 @@ def build_block( ) ) + metrics.lean_block_proposal_attestation_build_phase_seconds.labels( + phase="compact_ffi", + ).observe(time.perf_counter() - compact_start) + metrics.lean_block_proposal_attestation_builds_total.inc() + metrics.lean_block_proposal_child_payloads_consumed_total.inc(child_payloads_consumed) + metrics.lean_block_proposal_attestation_data_selected.observe( + len(processed_att_data) + ) + metrics.lean_block_proposal_aggregates_selected.observe( + len(aggregated_signatures) + ) + # Create the final block with selected attestations. final_block = self.block_class( slot=slot, @@ -1570,9 +1601,9 @@ def update_safe_target(self, store: LstarStore) -> LstarStore: # Compute the 2/3 supermajority threshold. # # A block needs at least this many attestation votes to be "safe". - # The ceiling division (negation trick) ensures we round UP. + # The threshold is rounded UP so a strict majority is required. # For example, 100 validators => threshold is 67, not 66. - min_target_score = -(-num_validators * 2 // 3) + min_target_score = math.ceil(int(num_validators) * 2 / 3) # Unpack "new" payloads into a flat validator -> vote mapping. # "Known" is excluded by design. @@ -1602,13 +1633,27 @@ def update_safe_target(self, store: LstarStore) -> LstarStore: # The head and attestation pools remain unchanged. return store.model_copy(update={"safe_target": safe_target}) - def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregatedAttestation]]: + def aggregate( + self, + store: LstarStore, + *, + skip_trivial_inputs: bool = True, + ) -> tuple[LstarStore, list[SignedAggregatedAttestation]]: """Turn raw validator votes into compact aggregated attestations. Validators cast individual signatures over gossip. Before those votes can influence fork choice or be included in a block, they must be combined into compact cryptographic proofs. + ``skip_trivial_inputs`` (default ``True``) is an **aggregator-role** + policy: when set, the ``1 raw + 0 children`` shape is skipped because + a single-validator "aggregate" carries no consensus signal beyond the + raw gossip sig already on the network (see issue #747). Interval-2 + aggregator ticks use the default. Block-building callers that must + fold every chosen ``att_data`` into ``latest_known_aggregated_payloads`` + (including lone gossip sigs) pass ``skip_trivial_inputs=False`` — + see ``BlockSpec`` in the consensus-testing filler. + The store holds three pools of attestation evidence: - **Gossip signatures**: individual validator votes arriving in real-time. @@ -1675,13 +1720,19 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate if e.validator_id not in covered ] - # The aggregation layer enforces a minimum: either at least one - # raw signature, or at least two child proofs to merge. - # - # A lone child proof is already a valid proof — nothing to do. + # Always skip cases where there is nothing to aggregate: + # - 0 raw + 0 children: nothing to aggregate. + # - 0 raw + 1 child: a lone child proof is already valid. if not raw_entries and len(child_proofs) < 2: continue + # Aggregator-role optimization (``skip_trivial_inputs=True``, the + # default): skip ``1 raw + 0 children``. Block-building callers + # pass ``skip_trivial_inputs=False`` so every gossip sig they + # seeded is folded into ``latest_known_aggregated_payloads``. + if skip_trivial_inputs and not child_proofs and len(raw_entries) <= 1: + continue + # Encode raw signers as a compact bitfield when present. # Child-only aggregation (no raw signatures) must pass None. if raw_entries: diff --git a/src/lean_spec/subspecs/metrics/registry.py b/src/lean_spec/subspecs/metrics/registry.py index d8306a4e2..c7022225a 100644 --- a/src/lean_spec/subspecs/metrics/registry.py +++ b/src/lean_spec/subspecs/metrics/registry.py @@ -45,6 +45,54 @@ REORG_DEPTH_BUCKETS = (1, 2, 3, 5, 7, 10, 20, 30, 50, 100) """Block count. Reorg depths above 10 are rare and signal network issues.""" +BLOCK_PROPOSAL_ATTESTATION_PHASE_BUCKETS = ( + 0.001, + 0.005, + 0.01, + 0.025, + 0.05, + 0.1, + 0.25, + 0.5, + 1, + 2, + 4, + 8, +) +"""Seconds. Phase-level time inside block-proposal attestation selection.""" + +BLOCK_PROPOSAL_ATTESTATION_DATA_BUCKETS = (0, 1, 2, 4, 8, 16, 32) +"""Distinct AttestationData entries selected per proposal build.""" + +BLOCK_PROPOSAL_AGGREGATES_BUCKETS = (0, 1, 2, 4, 8, 16, 32, 64, 128) +"""Aggregated signature proofs in the proposal result after compaction.""" + +BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES = ( + "select_payloads", + "compact_ffi", + "stf_simulate", +) +"""Phase labels for lean_block_proposal_attestation_build_phase_seconds.""" + +# Section labels for attestation aggregate coverage gauges. These match the +# names printed in slot/report logs: timely, late, block, combined, +# agg_start_new, proposal_payloads, proposal_gossip, and proposal_combined. +# Slot is the X-axis (time series progression), not a label dimension. +ATTESTATION_AGGREGATE_COVERAGE_SECTIONS = ( + "timely", + "late", + "block", + "combined", + "agg_start_new", + "proposal_payloads", + "proposal_gossip", + "proposal_combined", +) +"""Coverage report sections keyed by attestation aggregate source.""" + +ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS = ("block_only", "timely_only") +"""Validator coverage delta directions between block and timely pre-merge payloads.""" + class _NoOpMetric: """ @@ -128,6 +176,24 @@ class MetricsRegistry: """Running count of chain head reorganizations.""" lean_fork_choice_reorg_depth: Histogram | _NoOpMetric = _NOOP """Number of blocks rolled back during each reorg event.""" + lean_attestation_aggregate_coverage_validators: Gauge | _NoOpMetric = _NOOP + """Validator coverage in attestation aggregate reports, by section and subnet.""" + lean_attestation_aggregate_coverage_subnets: Gauge | _NoOpMetric = _NOOP + """Number of covered subnets in attestation aggregate reports, by section.""" + lean_attestation_aggregate_coverage_diff_validators: Gauge | _NoOpMetric = _NOOP + """Validator coverage delta between block payloads and timely pre-merge payloads.""" + + # Block proposal attestation selection (build_block fixed-point loop) + lean_block_proposal_attestation_build_phase_seconds: Histogram | _NoOpMetric = _NOOP + """Phase-level time in block-proposal attestation selection.""" + lean_block_proposal_attestation_builds_total: Counter | _NoOpMetric = _NOOP + """Completed block-proposal attestation selection runs.""" + lean_block_proposal_child_payloads_consumed_total: Counter | _NoOpMetric = _NOOP + """Child aggregated payloads selected during greedy proof picking.""" + lean_block_proposal_attestation_data_selected: Histogram | _NoOpMetric = _NOOP + """Distinct AttestationData entries in the proposal block body.""" + lean_block_proposal_aggregates_selected: Histogram | _NoOpMetric = _NOOP + """Aggregated signature proofs in the proposal result after compaction.""" # State transition lean_latest_justified_slot: Gauge | _NoOpMetric = _NOOP @@ -246,6 +312,88 @@ def init( buckets=REORG_DEPTH_BUCKETS, registry=reg, ) + # Attestation aggregate coverage (leanMetrics: Fork-Choice Metrics) + # + # `subnet="combined"` is the all-subnet validator total for the section; + # `subnet="subnet_N"` is that section's validator coverage on one subnet. + self.lean_attestation_aggregate_coverage_validators = Gauge( + "lean_attestation_aggregate_coverage_validators", + ( + "Validator coverage in attestation aggregate reports, labeled by " + "section and subnet. subnet=combined is the section total; " + "subnet=subnet_N is per-subnet coverage. Updated each slot " + "(slot is the X-axis)." + ), + ["section", "subnet"], + registry=reg, + ) + self.lean_attestation_aggregate_coverage_subnets = Gauge( + "lean_attestation_aggregate_coverage_subnets", + ( + "Number of covered subnets in attestation aggregate reports, " + "labeled by section. Updated each slot (slot is the X-axis)." + ), + ["section"], + registry=reg, + ) + self.lean_attestation_aggregate_coverage_diff_validators = Gauge( + "lean_attestation_aggregate_coverage_diff_validators", + ( + "Validator coverage delta between block payloads and timely " + "pre-merge payloads, labeled by direction (block_only|timely_only). " + "Updated each slot (slot is the X-axis)." + ), + ["direction"], + registry=reg, + ) + for section in ATTESTATION_AGGREGATE_COVERAGE_SECTIONS: + self.lean_attestation_aggregate_coverage_validators.labels( + section=section, + subnet="combined", + ).set(0) + self.lean_attestation_aggregate_coverage_subnets.labels(section=section).set(0) + for direction in ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS: + self.lean_attestation_aggregate_coverage_diff_validators.labels( + direction=direction, + ).set(0) + + # Block proposal attestation selection (build_block / getProposalAttestations) + self.lean_block_proposal_attestation_build_phase_seconds = Histogram( + "lean_block_proposal_attestation_build_phase_seconds", + ( + "Phase-level time in block-proposal attestation selection: " + "select_payloads (greedy child-payload pick), compact_ffi " + "(recursive merge), stf_simulate (candidate block STF)." + ), + ["phase"], + buckets=BLOCK_PROPOSAL_ATTESTATION_PHASE_BUCKETS, + registry=reg, + ) + self.lean_block_proposal_attestation_builds_total = Counter( + "lean_block_proposal_attestation_builds_total", + "Completed block-proposal attestation selection runs (one per proposal attempt).", + registry=reg, + ) + self.lean_block_proposal_child_payloads_consumed_total = Counter( + "lean_block_proposal_child_payloads_consumed_total", + ( + "Child aggregated payloads selected during greedy proof picking " + "(before recursive compaction)." + ), + registry=reg, + ) + self.lean_block_proposal_attestation_data_selected = Histogram( + "lean_block_proposal_attestation_data_selected", + "Distinct AttestationData entries in the proposal block body.", + buckets=BLOCK_PROPOSAL_ATTESTATION_DATA_BUCKETS, + registry=reg, + ) + self.lean_block_proposal_aggregates_selected = Histogram( + "lean_block_proposal_aggregates_selected", + "Aggregated signature proofs in the proposal result after compaction.", + buckets=BLOCK_PROPOSAL_AGGREGATES_BUCKETS, + registry=reg, + ) # State transition (leanMetrics: State Transition Metrics) self.lean_latest_justified_slot = Gauge( diff --git a/tests/lean_spec/subspecs/metrics/test_registry.py b/tests/lean_spec/subspecs/metrics/test_registry.py new file mode 100644 index 000000000..504482151 --- /dev/null +++ b/tests/lean_spec/subspecs/metrics/test_registry.py @@ -0,0 +1,74 @@ +"""Tests for the Prometheus metrics registry.""" + +from __future__ import annotations + +from collections.abc import Iterator + +import pytest +from prometheus_client import CollectorRegistry + +from lean_spec.subspecs.metrics.registry import ( + ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS, + ATTESTATION_AGGREGATE_COVERAGE_SECTIONS, + BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES, + registry, +) + + +@pytest.fixture(autouse=True) +def _reset_registry() -> Iterator[None]: + """Ensure metrics are uninitialized before and after each test.""" + registry.reset() + registry._initialized = False + yield + registry.reset() + registry._initialized = False + + +def test_attestation_aggregate_coverage_metrics_registered() -> None: + """Coverage gauges are created with default combined-subnet series.""" + test_reg = CollectorRegistry() + registry.init(registry=test_reg) + + for section in ATTESTATION_AGGREGATE_COVERAGE_SECTIONS: + assert ( + test_reg.get_sample_value( + "lean_attestation_aggregate_coverage_validators", + {"section": section, "subnet": "combined"}, + ) + == 0.0 + ) + assert ( + test_reg.get_sample_value( + "lean_attestation_aggregate_coverage_subnets", + {"section": section}, + ) + == 0.0 + ) + + for direction in ATTESTATION_AGGREGATE_COVERAGE_DIFF_DIRECTIONS: + assert ( + test_reg.get_sample_value( + "lean_attestation_aggregate_coverage_diff_validators", + {"direction": direction}, + ) + == 0.0 + ) + + +def test_block_proposal_attestation_build_metrics_registered() -> None: + """Block-proposal attestation selection metrics are registered on init.""" + test_reg = CollectorRegistry() + registry.init(registry=test_reg) + + for phase in BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES: + assert ( + test_reg.get_sample_value( + "lean_block_proposal_attestation_build_phase_seconds_count", + {"phase": phase}, + ) + is None + ) + + assert test_reg.get_sample_value("lean_block_proposal_attestation_builds_total") == 0.0 + assert test_reg.get_sample_value("lean_block_proposal_child_payloads_consumed_total") == 0.0 From cb2d0668632addefa60c145ade4a755ce4d5de1a Mon Sep 17 00:00:00 2001 From: ch4r10t33r Date: Thu, 21 May 2026 19:38:42 +0100 Subject: [PATCH 2/4] metrics: rename proposal build phase compact_ffi to compact The merge step is spec-level recursive aggregation, not an FFI boundary specific to Zig clients. --- src/lean_spec/forks/lstar/spec.py | 2 +- src/lean_spec/subspecs/metrics/registry.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index 173891258..d214404fb 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -863,7 +863,7 @@ def build_block( ) metrics.lean_block_proposal_attestation_build_phase_seconds.labels( - phase="compact_ffi", + phase="compact", ).observe(time.perf_counter() - compact_start) metrics.lean_block_proposal_attestation_builds_total.inc() metrics.lean_block_proposal_child_payloads_consumed_total.inc(child_payloads_consumed) diff --git a/src/lean_spec/subspecs/metrics/registry.py b/src/lean_spec/subspecs/metrics/registry.py index c7022225a..44aaee013 100644 --- a/src/lean_spec/subspecs/metrics/registry.py +++ b/src/lean_spec/subspecs/metrics/registry.py @@ -69,7 +69,7 @@ BLOCK_PROPOSAL_ATTESTATION_BUILD_PHASES = ( "select_payloads", - "compact_ffi", + "compact", "stf_simulate", ) """Phase labels for lean_block_proposal_attestation_build_phase_seconds.""" @@ -362,8 +362,9 @@ def init( "lean_block_proposal_attestation_build_phase_seconds", ( "Phase-level time in block-proposal attestation selection: " - "select_payloads (greedy child-payload pick), compact_ffi " - "(recursive merge), stf_simulate (candidate block STF)." + "select_payloads (greedy child-payload pick), compact " + "(recursive merge of proofs per AttestationData), " + "stf_simulate (candidate block STF)." ), ["phase"], buckets=BLOCK_PROPOSAL_ATTESTATION_PHASE_BUCKETS, From c557a96a533bdb2111d2293f7df8f6aeb4c28299 Mon Sep 17 00:00:00 2001 From: ch4r10t33r Date: Thu, 21 May 2026 19:48:46 +0100 Subject: [PATCH 3/4] =?UTF-8?q?style:=20ruff=20format=20spec.py=20?= =?UTF-8?q?=E2=80=94=20collapse=20short=20observe()=20calls=20onto=20one?= =?UTF-8?q?=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lean_spec/forks/lstar/spec.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lean_spec/forks/lstar/spec.py b/src/lean_spec/forks/lstar/spec.py index d214404fb..73288eb4f 100644 --- a/src/lean_spec/forks/lstar/spec.py +++ b/src/lean_spec/forks/lstar/spec.py @@ -867,12 +867,8 @@ def build_block( ).observe(time.perf_counter() - compact_start) metrics.lean_block_proposal_attestation_builds_total.inc() metrics.lean_block_proposal_child_payloads_consumed_total.inc(child_payloads_consumed) - metrics.lean_block_proposal_attestation_data_selected.observe( - len(processed_att_data) - ) - metrics.lean_block_proposal_aggregates_selected.observe( - len(aggregated_signatures) - ) + metrics.lean_block_proposal_attestation_data_selected.observe(len(processed_att_data)) + metrics.lean_block_proposal_aggregates_selected.observe(len(aggregated_signatures)) # Create the final block with selected attestations. final_block = self.block_class( From 377343f11fa26ca9801514d9c151fca0a47ce8cf Mon Sep 17 00:00:00 2001 From: ch4r10t33r Date: Thu, 21 May 2026 20:26:45 +0100 Subject: [PATCH 4/4] testing: fix aggregate skip_trivial_inputs for block filler and tests BlockSpec simulated block builds must pass skip_trivial_inputs=False so lone gossip sigs fold into known payloads. Update the grouping test to seed two validators per attestation data and add coverage for the default aggregator skip of 1 raw + 0 children. --- .../test_types/block_spec.py | 2 +- .../forkchoice/test_store_attestations.py | 31 +++++++++++++--- .../lstar/state/test_state_aggregation.py | 35 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/testing/src/consensus_testing/test_types/block_spec.py b/packages/testing/src/consensus_testing/test_types/block_spec.py index f95b2f459..4b736fdd9 100644 --- a/packages/testing/src/consensus_testing/test_types/block_spec.py +++ b/packages/testing/src/consensus_testing/test_types/block_spec.py @@ -523,7 +523,7 @@ def build_signed_block_with_store( # Aggregation runs on a local clone: gossip pools mutate here, but the # caller's gossip-signature view must not be consumed by this simulated # build. Only the freshly aggregated Type-1 payloads propagate back. - aggregation_store, _ = spec.aggregate(store) + aggregation_store, _ = spec.aggregate(store, skip_trivial_inputs=False) merged_store = spec.accept_new_attestations(aggregation_store) # Build the block through the spec's State.build_block(). diff --git a/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py b/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py index 496895b31..c42d17fa8 100644 --- a/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py +++ b/tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py @@ -593,12 +593,33 @@ def test_multiple_attestation_data_grouped_separately( source=att_data_1.source, ) - # Validators 1 attests to data_1, validator 2 attests to data_2 - sig_1 = key_manager.sign_attestation_data(ValidatorIndex(1), att_data_1) - sig_2 = key_manager.sign_attestation_data(ValidatorIndex(2), att_data_2) + # Validators 1, 3 attest to data_1; validators 2, 0 attest to data_2. + # Two distinct sigs per att_data is the minimum non-trivial shape: + # `aggregate()` skips the `1 raw + 0 children` case (a single-validator + # "aggregate" carries no information the raw gossip sig doesn't + # already carry), so this test must seed at least two raw sigs per + # `att_data` for the per-data grouping it is asserting. attestation_signatures = { - att_data_1: {AttestationSignatureEntry(ValidatorIndex(1), sig_1)}, - att_data_2: {AttestationSignatureEntry(ValidatorIndex(2), sig_2)}, + att_data_1: { + AttestationSignatureEntry( + ValidatorIndex(1), + key_manager.sign_attestation_data(ValidatorIndex(1), att_data_1), + ), + AttestationSignatureEntry( + ValidatorIndex(3), + key_manager.sign_attestation_data(ValidatorIndex(3), att_data_1), + ), + }, + att_data_2: { + AttestationSignatureEntry( + ValidatorIndex(2), + key_manager.sign_attestation_data(ValidatorIndex(2), att_data_2), + ), + AttestationSignatureEntry( + ValidatorIndex(0), + key_manager.sign_attestation_data(ValidatorIndex(0), att_data_2), + ), + }, } store = base_store.model_copy( diff --git a/tests/lean_spec/forks/lstar/state/test_state_aggregation.py b/tests/lean_spec/forks/lstar/state/test_state_aggregation.py index cf3599c0a..6579a0b33 100644 --- a/tests/lean_spec/forks/lstar/state/test_state_aggregation.py +++ b/tests/lean_spec/forks/lstar/state/test_state_aggregation.py @@ -141,6 +141,41 @@ def test_aggregate_with_empty_attestation_signatures( assert results == [] +def test_aggregate_skips_single_gossip_sig_with_no_children( + container_key_manager: XmssKeyManager, + spec: LstarSpec, +) -> None: + """Trivial 1 raw sig + 0 children case: aggregate returns nothing. + + A single-validator "aggregate" carries no information the raw gossip + sig doesn't already carry — the sig is on the per-subnet + `attestation_signatures` gossip topic at sign time, so any peer + aggregator can fold it in as a raw entry next round. The recursive + STARK prover is constant-cost in input size, so building a + 1-validator proof spends the full prover budget for zero consensus + signal. The unconsumed gossip sig must remain in + `store.attestation_signatures` so it is folded in by a future round + once another sig or a child shows up. + """ + store = make_store(num_validators=2, key_manager=container_key_manager) + source = Checkpoint(root=make_bytes32(1), slot=Slot(0)) + att_data = make_attestation_data_simple( + Slot(2), make_bytes32(3), make_bytes32(4), source=source + ) + sig_entry = AttestationSignatureEntry( + ValidatorIndex(0), + container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data), + ) + attestation_signatures = {att_data: {sig_entry}} + + store = store.model_copy(update={"attestation_signatures": attestation_signatures}) + updated_store, results = spec.aggregate(store) + + assert results == [] + # The lone sig must survive untouched for a later, non-trivial pass. + assert updated_store.attestation_signatures == attestation_signatures + + def test_aggregated_signatures_with_multiple_data_groups( container_key_manager: XmssKeyManager, spec: LstarSpec,