Skip to content

Commit ebf803e

Browse files
committed
lstar: skip trivial 1-raw-sig + 0-children case in aggregate()
Extend the early-skip predicate in `aggregate()` so an `AttestationData` with exactly one raw gossip sig and no child proofs is not aggregated. Why: - A 1-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 (`xmss/aggregation.py:aggregate`) is roughly constant-cost in input size — building a 1-validator proof costs the same as building a 32-validator one. On the multi-client devnet this caused two zeam aggregators to spend ~10.8 s of FFI per worker run on a single-validator proof, dropping ~50% of slot triggers as `in_flight`. See blockblaz/zeam#907. The unconsumed gossip sig is preserved in `store.attestation_signatures` by the existing bookkeeping (only sigs whose `att_data` produced an aggregate are pruned), so a future round folds it in once another sig or a child shows up. Test changes: - Add `test_aggregate_skips_single_gossip_sig_with_no_children` covering the new skip case and asserting the sig survives in the store. - Update `test_multiple_attestation_data_grouped_separately` to seed two raw sigs per `att_data`. The original test put one sig per data, which is now (correctly) the trivial case that does not produce a proof; the test's intent — that two distinct `AttestationData` produce two distinct proofs — is preserved by upgrading both groups to non-trivial inputs. Closes #747
1 parent 825bec6 commit ebf803e

3 files changed

Lines changed: 82 additions & 8 deletions

File tree

src/lean_spec/forks/lstar/spec.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1676,10 +1676,28 @@ def aggregate(self, store: LstarStore) -> tuple[LstarStore, list[SignedAggregate
16761676
if e.validator_id not in covered
16771677
]
16781678

1679-
# The aggregation layer enforces a minimum: either at least one
1680-
# raw signature, or at least two child proofs to merge.
1679+
# Skip cases where running the prover provides no consensus value:
16811680
#
1682-
# A lone child proof is already a valid proof — nothing to do.
1681+
# - 0 raw + 0 children: nothing to aggregate.
1682+
# - 0 raw + 1 child: a lone child proof is already a valid
1683+
# proof; nothing to do.
1684+
# - 1 raw + 0 children: a single-validator "aggregate" carries
1685+
# no information the raw gossip sig
1686+
# doesn't already carry — the sig is on
1687+
# the per-subnet `attestation_signatures`
1688+
# gossip topic at sign time, so any peer
1689+
# aggregator can fold it in as a raw
1690+
# entry next round. The recursive STARK
1691+
# prover is constant-cost in input size,
1692+
# so building a 1-validator proof spends
1693+
# the full prover budget for zero
1694+
# consensus signal. The unconsumed gossip
1695+
# sig stays in `store.attestation_signatures`
1696+
# (see bookkeeping below) and gets folded
1697+
# in by a future round once another sig
1698+
# or a child shows up.
1699+
if not child_proofs and len(raw_entries) <= 1:
1700+
continue
16831701
if not raw_entries and len(child_proofs) < 2:
16841702
continue
16851703

tests/lean_spec/forks/lstar/forkchoice/test_store_attestations.py

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -593,12 +593,33 @@ def test_multiple_attestation_data_grouped_separately(
593593
source=att_data_1.source,
594594
)
595595

596-
# Validators 1 attests to data_1, validator 2 attests to data_2
597-
sig_1 = key_manager.sign_attestation_data(ValidatorIndex(1), att_data_1)
598-
sig_2 = key_manager.sign_attestation_data(ValidatorIndex(2), att_data_2)
596+
# Validators 1, 3 attest to data_1; validators 2, 0 attest to data_2.
597+
# Two distinct sigs per att_data is the minimum non-trivial shape:
598+
# `aggregate()` skips the `1 raw + 0 children` case (a single-validator
599+
# "aggregate" carries no information the raw gossip sig doesn't
600+
# already carry), so this test must seed at least two raw sigs per
601+
# `att_data` for the per-data grouping it is asserting.
599602
attestation_signatures = {
600-
att_data_1: {AttestationSignatureEntry(ValidatorIndex(1), sig_1)},
601-
att_data_2: {AttestationSignatureEntry(ValidatorIndex(2), sig_2)},
603+
att_data_1: {
604+
AttestationSignatureEntry(
605+
ValidatorIndex(1),
606+
key_manager.sign_attestation_data(ValidatorIndex(1), att_data_1),
607+
),
608+
AttestationSignatureEntry(
609+
ValidatorIndex(3),
610+
key_manager.sign_attestation_data(ValidatorIndex(3), att_data_1),
611+
),
612+
},
613+
att_data_2: {
614+
AttestationSignatureEntry(
615+
ValidatorIndex(2),
616+
key_manager.sign_attestation_data(ValidatorIndex(2), att_data_2),
617+
),
618+
AttestationSignatureEntry(
619+
ValidatorIndex(0),
620+
key_manager.sign_attestation_data(ValidatorIndex(0), att_data_2),
621+
),
622+
},
602623
}
603624

604625
store = base_store.model_copy(

tests/lean_spec/forks/lstar/state/test_state_aggregation.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,41 @@ def test_aggregate_with_empty_attestation_signatures(
141141
assert results == []
142142

143143

144+
def test_aggregate_skips_single_gossip_sig_with_no_children(
145+
container_key_manager: XmssKeyManager,
146+
spec: LstarSpec,
147+
) -> None:
148+
"""Trivial 1 raw sig + 0 children case: aggregate returns nothing.
149+
150+
A single-validator "aggregate" carries no information the raw gossip
151+
sig doesn't already carry — the sig is on the per-subnet
152+
`attestation_signatures` gossip topic at sign time, so any peer
153+
aggregator can fold it in as a raw entry next round. The recursive
154+
STARK prover is constant-cost in input size, so building a
155+
1-validator proof spends the full prover budget for zero consensus
156+
signal. The unconsumed gossip sig must remain in
157+
`store.attestation_signatures` so it is folded in by a future round
158+
once another sig or a child shows up.
159+
"""
160+
store = make_store(num_validators=2, key_manager=container_key_manager)
161+
source = Checkpoint(root=make_bytes32(1), slot=Slot(0))
162+
att_data = make_attestation_data_simple(
163+
Slot(2), make_bytes32(3), make_bytes32(4), source=source
164+
)
165+
sig_entry = AttestationSignatureEntry(
166+
ValidatorIndex(0),
167+
container_key_manager.sign_attestation_data(ValidatorIndex(0), att_data),
168+
)
169+
attestation_signatures = {att_data: {sig_entry}}
170+
171+
store = store.model_copy(update={"attestation_signatures": attestation_signatures})
172+
updated_store, results = spec.aggregate(store)
173+
174+
assert results == []
175+
# The lone sig must survive untouched for a later, non-trivial pass.
176+
assert updated_store.attestation_signatures == attestation_signatures
177+
178+
144179
def test_aggregated_signatures_with_multiple_data_groups(
145180
container_key_manager: XmssKeyManager,
146181
spec: LstarSpec,

0 commit comments

Comments
 (0)