Filter gossip signatures by attestation subnet membership#221
Filter gossip signatures by attestation subnet membership#221pablodeymo merged 2 commits intomainfrom
Conversation
The spec (store.py:385-392, 605-612) requires that gossip signatures are only stored if the attester is in the same attestation subnet as the local validator. With ATTESTATION_COMMITTEE_COUNT=1 this is always true (all validators are in subnet 0), so there is no behavioral change. This adds forward compatibility for when the committee count increases. Both gossip attestation and proposer signature storage paths now check subnet membership before inserting into the gossip signatures map.
🤖 Kimi Code ReviewReview SummaryThe PR introduces subnet-based filtering for gossip signatures based on validator committee assignments. While the approach is sound, several issues need attention: Critical Issues
Security & Correctness
Code Quality
Recommendations
Automated review by Kimi (Moonshot AI) · custom prompt |
🤖 Claude Code ReviewHere is the review for PR #221: PR #221 Review — Filter gossip signatures by attestation subnet membershipSummaryClean, minimal spec-compliance patch. The implementation correctly mirrors the two filter sites in IssuesMinor — Misleading comment in // passes &[] to on_block (tests don't have local validators, and with
// ATTESTATION_COMMITTEE_COUNT=1 all subnets match anyway)The second half of the reasoning is incorrect. When Minor — Redundant
let validator_ids = self.key_manager.validator_ids(); // Vec allocation
store::on_block(&mut self.store, signed_block, &validator_ids)?;
// ...
metrics::update_validators_count(self.key_manager.validator_ids().len() as u64); // second allocationThe Observations (no action required)
VerdictTwo small items: a misleading comment and a redundant allocation. The core logic is correct and faithfully matches the spec. Good to merge once the comment is fixed. Automated review by Claude (Anthropic) · custom prompt |
🤖 Codex Code Review
I couldn’t run tests locally due sandboxed Automated review by OpenAI Codex · custom prompt |
Greptile SummaryThis PR implements a spec compliance fix (audit finding F-04) that filters gossip signature storage based on attestation subnet membership, matching the leanSpec reference implementation. Both Key changes:
Zero behavioral change today: With Minor concern: The Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| crates/blockchain/src/store.rs | Core change: adds compute_subnet_id helper and ATTESTATION_COMMITTEE_COUNT constant; both on_gossip_attestation and on_block_core now gate gossip signature storage behind a subnet-membership check. Logic is correct for production (non-empty validator IDs), but the in_our_subnet check silently drops signatures when local_validator_ids is empty, warranting a clarifying comment. |
| crates/blockchain/src/lib.rs | Call sites in process_block and on_gossip_attestation correctly plumb self.key_manager.validator_ids() to the updated store functions; no logic issues found. |
| crates/blockchain/tests/signature_spectests.rs | Tests updated to pass &[] to on_block; functionally correct since tests only check block acceptance/rejection, not signature storage. The justification ("all subnets match with ATTESTATION_COMMITTEE_COUNT=1") is misleading — with an empty slice, .any() always returns false regardless of the constant. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[Receive gossiped attestation / block] --> B{Signature\nverification\nrequired?}
B -- "verify=false\n(on_block_without_verification)" --> C[Insert dummy proof\nno signature stored]
B -- "verify=true" --> D[verify_signatures]
D --> E{Verification\npassed?}
E -- No --> F[Return error]
E -- Yes --> G[Store attestation data\ncontent-addressed, always]
G --> H["compute_subnet_id(attester)"]
H --> I{Any local validator\nin same subnet?}
I -- "local_validator_ids = &[]\n→ any() = false" --> J[Skip signature storage]
I -- "Subnet match found\n(always true if ATTESTATION_COMMITTEE_COUNT=1\nand node has ≥1 validator)" --> K[insert_gossip_signature\nupdate metrics]
K --> L[Return Ok]
J --> L
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/blockchain/tests/signature_spectests.rs
Line: 58
Comment:
**Incorrect justification for empty slice argument**
The comment in the PR description (and by implication this call site) says "with `ATTESTATION_COMMITTEE_COUNT=1` all subnets match anyway" to justify passing `&[]`. This reasoning is wrong: when `local_validator_ids` is empty, `.any()` on an empty iterator **always returns `false`**, regardless of `ATTESTATION_COMMITTEE_COUNT`. So passing `&[]` actually means **no proposer gossip signature is stored** in these tests — not that "all subnets match."
This is harmless here because `signature_spectests.rs` only checks `result.is_ok()`, not gossip signature storage. But the stated rationale is misleading and could cause confusion for future readers who assume `&[]` behaves the same as passing real validators when `ATTESTATION_COMMITTEE_COUNT = 1`.
Consider adding a clarifying comment, e.g.:
```suggestion
let result = store::on_block(&mut st, signed_block, &[]);
// &[] means no gossip signatures will be stored; that's intentional here
// because signature_spectests only validates block acceptance/rejection,
// not gossip signature collection.
```
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: crates/blockchain/src/store.rs
Line: 658-661
Comment:
**`in_our_subnet` always false when `local_validator_ids` is empty**
When `local_validator_ids` is `&[]` (as passed from tests or any node with no registered validators), `.any()` returns `false` unconditionally — so the proposer's gossip signature is silently dropped, even though the signature was already fully verified by `verify_signatures` above. With `ATTESTATION_COMMITTEE_COUNT = 1` this is currently harmless for production nodes (they always have at least one registered validator), but if a non-validator aggregator node were ever deployed without local keys, it would store no gossip signatures at all, silently degrading aggregation quality.
This is also the exact edge case that makes the comment "with `ATTESTATION_COMMITTEE_COUNT=1` all subnets match anyway" misleading — it holds only when `local_validator_ids` is non-empty. A small defensive guard or doc comment would help:
```rust
// NOTE: if local_validator_ids is empty, no signatures are stored — this matches
// the spec's "self.validator_id is None" path but means non-validator aggregators
// will not collect signatures.
let in_our_subnet = local_validator_ids
.iter()
.any(|&vid| compute_subnet_id(vid) == proposer_subnet);
```
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: abd5ad9
| let attester_subnet = compute_subnet_id(validator_id); | ||
| let in_our_subnet = local_validator_ids | ||
| .iter() | ||
| .any(|&vid| compute_subnet_id(vid) == attester_subnet); |
There was a problem hiding this comment.
We still need to support multiple validators at the P2P layer, but this is a good first step.
Motivation
A spec-to-code compliance audit (F-04) identified that gossip signatures were being stored unconditionally, without checking whether the attester belongs to the same attestation subnet as the local validator. The leanSpec reference implementation filters signatures by subnet in two places:
1. Gossip attestation path (
store.py:385-392):2. Proposer signature in on_block (
store.py:605-612):The subnet ID is computed as
validator_id % ATTESTATION_COMMITTEE_COUNT(validator.py:31-40).Why this has no impact today
ATTESTATION_COMMITTEE_COUNT = 1— every validator maps to subnet 0 (validator_id % 1 = 0), so the filter is always true. This PR introduces zero behavioral change.Why it matters for forward compatibility
If
ATTESTATION_COMMITTEE_COUNTincreases (e.g., to 4 with 1000 validators):The aggregation cost is the real concern — leanVM proving is the most expensive operation in the system, and aggregating 4x more signatures would directly multiply proving time.
Description
New constant and helper
ATTESTATION_COMMITTEE_COUNT = 1— matches speccompute_subnet_id(validator_id)—validator_id % ATTESTATION_COMMITTEE_COUNTwith#[allow(clippy::modulo_one)]since the constant is 1 todaySignature changes
on_gossip_attestationnow takeslocal_validator_ids: &[u64]:on_block/on_block_corenow takeslocal_validator_ids: &[u64]:on_block_without_verificationpasses&[](test-only path, no signatures stored anyway)BlockChainServer(call sites inlib.rs):process_blockandon_gossip_attestationnow passself.key_manager.validator_ids()to the store functionsTest updates
signature_spectests.rs: passes&[]toon_block(tests don't have local validators, and withATTESTATION_COMMITTEE_COUNT=1all subnets match anyway)How to test
Test plan
make fmtpassesmake lintpassesmake testpasses (all 112 tests, 0 failures)ATTESTATION_COMMITTEE_COUNTis increased, verify only same-subnet signatures are stored