Skip to content

Commit 4a6ef07

Browse files
committed
refactor: pair (attestation, proof) tuples in block building
`compact_attestations` and `extend_proofs_greedily` previously tracked attestations and signature proofs in parallel `Vec`s guarded by a `debug_assert_eq!` on length. Switching to `Vec<(att, proof)>` makes the invariant structural: the two lists can no longer drift out of sync, and the debug assertion is gone. Paired tuples flow end-to-end through `build_block`, unzipping only at the final SSZ boundary. Also eliminates an `AggregatedXMSS::clone()` per child inside `aggregate_mixed` / `aggregate_proofs`: the new `unzip` pattern lets us borrow pubkey slices (required by `xmss_aggregate`'s tuple type) while moving the large aggregate values into the child list. Removes the dead `to_children_refs` helper.
1 parent 2c08f8b commit 4a6ef07

2 files changed

Lines changed: 168 additions & 95 deletions

File tree

crates/blockchain/src/store.rs

Lines changed: 155 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -1169,21 +1169,23 @@ fn union_aggregation_bits(a: &AggregationBits, b: &AggregationBits) -> Aggregati
11691169
/// - Single entry: kept as-is.
11701170
/// - Multiple entries: merged into one using recursive proof aggregation
11711171
/// (leanSpec PR #510).
1172+
///
1173+
/// The input pairs each attestation with its signature proof so the two
1174+
/// Vecs can never drift out of sync (the prior signature used separate
1175+
/// `Vec<AggregatedAttestation>` and `Vec<AggregatedSignatureProof>` guarded
1176+
/// by a debug-only length assertion).
11721177
fn compact_attestations(
1173-
attestations: Vec<AggregatedAttestation>,
1174-
proofs: Vec<AggregatedSignatureProof>,
1178+
entries: Vec<(AggregatedAttestation, AggregatedSignatureProof)>,
11751179
head_state: &State,
1176-
) -> Result<(Vec<AggregatedAttestation>, Vec<AggregatedSignatureProof>), StoreError> {
1177-
debug_assert_eq!(attestations.len(), proofs.len());
1178-
1179-
if attestations.len() <= 1 {
1180-
return Ok((attestations, proofs));
1180+
) -> Result<Vec<(AggregatedAttestation, AggregatedSignatureProof)>, StoreError> {
1181+
if entries.len() <= 1 {
1182+
return Ok(entries);
11811183
}
11821184

11831185
// Group indices by AttestationData, preserving first-occurrence order
11841186
let mut order: Vec<AttestationData> = Vec::new();
11851187
let mut groups: HashMap<AttestationData, Vec<usize>> = HashMap::new();
1186-
for (i, att) in attestations.iter().enumerate() {
1188+
for (i, (att, _)) in entries.iter().enumerate() {
11871189
match groups.entry(att.data.clone()) {
11881190
std::collections::hash_map::Entry::Vacant(e) => {
11891191
order.push(e.key().clone());
@@ -1196,23 +1198,21 @@ fn compact_attestations(
11961198
}
11971199

11981200
// Fast path: no duplicates
1199-
if order.len() == attestations.len() {
1200-
return Ok((attestations, proofs));
1201+
if order.len() == entries.len() {
1202+
return Ok(entries);
12011203
}
12021204

12031205
// Wrap in Option so we can .take() items by index without cloning
12041206
let mut items: Vec<Option<(AggregatedAttestation, AggregatedSignatureProof)>> =
1205-
attestations.into_iter().zip(proofs).map(Some).collect();
1207+
entries.into_iter().map(Some).collect();
12061208

1207-
let mut compacted_atts = Vec::with_capacity(order.len());
1208-
let mut compacted_proofs = Vec::with_capacity(order.len());
1209+
let mut compacted = Vec::with_capacity(order.len());
12091210

12101211
for data in order {
12111212
let indices = &groups[&data];
12121213
if indices.len() == 1 {
1213-
let (att, proof) = items[indices[0]].take().expect("index used once");
1214-
compacted_atts.push(att);
1215-
compacted_proofs.push(proof);
1214+
let item = items[indices[0]].take().expect("index used once");
1215+
compacted.push(item);
12161216
continue;
12171217
}
12181218

@@ -1253,25 +1253,33 @@ fn compact_attestations(
12531253
.map_err(StoreError::SignatureAggregationFailed)?;
12541254

12551255
let merged_proof = AggregatedSignatureProof::new(merged_bits.clone(), merged_proof_data);
1256-
1257-
compacted_proofs.push(merged_proof);
1258-
compacted_atts.push(AggregatedAttestation {
1256+
let merged_att = AggregatedAttestation {
12591257
aggregation_bits: merged_bits,
12601258
data,
1261-
});
1259+
};
1260+
compacted.push((merged_att, merged_proof));
12621261
}
12631262

1264-
Ok((compacted_atts, compacted_proofs))
1263+
Ok(compacted)
12651264
}
12661265

12671266
/// Greedily select proofs maximizing new validator coverage.
12681267
///
12691268
/// For a single attestation data entry, picks proofs that cover the most
1270-
/// uncovered validators. Each selected proof produces one AggregatedAttestation.
1269+
/// uncovered validators. A proof is selected as long as it adds at least
1270+
/// one previously-uncovered validator; partially-overlapping participants
1271+
/// between selected proofs are allowed. `compact_attestations` later feeds
1272+
/// these proofs as children to `aggregate_proofs`, which delegates to
1273+
/// `xmss_aggregate` — that function tracks duplicate pubkeys across
1274+
/// children via its `dup_pub_keys` machinery, so overlap is supported by
1275+
/// the underlying aggregation scheme.
1276+
///
1277+
/// Each selected proof is appended to `selected` paired with its
1278+
/// corresponding AggregatedAttestation so the two lists can never drift
1279+
/// out of sync.
12711280
fn extend_proofs_greedily(
12721281
proofs: &[AggregatedSignatureProof],
1273-
selected_proofs: &mut Vec<AggregatedSignatureProof>,
1274-
attestations: &mut Vec<AggregatedAttestation>,
1282+
selected: &mut Vec<(AggregatedAttestation, AggregatedSignatureProof)>,
12751283
att_data: &AttestationData,
12761284
) {
12771285
if proofs.is_empty() {
@@ -1309,16 +1317,16 @@ fn extend_proofs_greedily(
13091317
.filter(|vid| !covered.contains(vid))
13101318
.collect();
13111319

1312-
attestations.push(AggregatedAttestation {
1320+
let att = AggregatedAttestation {
13131321
aggregation_bits: proof.participants.clone(),
13141322
data: att_data.clone(),
1315-
});
1316-
selected_proofs.push(proof.clone());
1323+
};
13171324

13181325
metrics::inc_pq_sig_aggregated_signatures();
13191326
metrics::inc_pq_sig_attestations_in_aggregated_signatures(new_covered.len() as u64);
13201327

13211328
covered.extend(new_covered);
1329+
selected.push((att, proof.clone()));
13221330
remaining_indices.remove(&best_idx);
13231331
}
13241332
}
@@ -1339,8 +1347,9 @@ fn build_block(
13391347
known_block_roots: &HashSet<H256>,
13401348
aggregated_payloads: &HashMap<H256, (AttestationData, Vec<AggregatedSignatureProof>)>,
13411349
) -> Result<(Block, Vec<AggregatedSignatureProof>, PostBlockCheckpoints), StoreError> {
1342-
let mut aggregated_attestations: Vec<AggregatedAttestation> = Vec::new();
1343-
let mut aggregated_signatures: Vec<AggregatedSignatureProof> = Vec::new();
1350+
// Paired (attestation, proof) tuples so the two can never drift out of
1351+
// sync. Unzipped once at the end for the block body and signatures list.
1352+
let mut selected: Vec<(AggregatedAttestation, AggregatedSignatureProof)> = Vec::new();
13441353

13451354
if !aggregated_payloads.is_empty() {
13461355
// Genesis edge case: when building on genesis (slot 0),
@@ -1382,21 +1391,18 @@ fn build_block(
13821391
processed_data_roots.insert(*data_root);
13831392
found_new = true;
13841393

1385-
extend_proofs_greedily(
1386-
proofs,
1387-
&mut aggregated_signatures,
1388-
&mut aggregated_attestations,
1389-
att_data,
1390-
);
1394+
extend_proofs_greedily(proofs, &mut selected, att_data);
13911395
}
13921396

13931397
if !found_new {
13941398
break;
13951399
}
13961400

13971401
// Check if justification advanced
1398-
let attestations: AggregatedAttestations = aggregated_attestations
1399-
.clone()
1402+
let attestations: AggregatedAttestations = selected
1403+
.iter()
1404+
.map(|(att, _)| att.clone())
1405+
.collect::<Vec<_>>()
14001406
.try_into()
14011407
.expect("attestation count exceeds limit");
14021408
let candidate = Block {
@@ -1421,8 +1427,11 @@ fn build_block(
14211427

14221428
// Compact: merge proofs sharing the same AttestationData via recursive
14231429
// aggregation so each AttestationData appears at most once (leanSpec #510).
1424-
let (aggregated_attestations, aggregated_signatures) =
1425-
compact_attestations(aggregated_attestations, aggregated_signatures, head_state)?;
1430+
let compacted = compact_attestations(selected, head_state)?;
1431+
1432+
// Unzip paired entries into the parallel lists the block expects.
1433+
let (aggregated_attestations, aggregated_signatures): (Vec<_>, Vec<_>) =
1434+
compacted.into_iter().unzip();
14261435

14271436
// Build final block
14281437
let attestations: AggregatedAttestations = aggregated_attestations
@@ -1891,28 +1900,28 @@ mod tests {
18911900
let bits_a = make_bits(&[0]);
18921901
let bits_b = make_bits(&[1]);
18931902

1894-
let atts = vec![
1895-
AggregatedAttestation {
1896-
aggregation_bits: bits_a.clone(),
1897-
data: data_a.clone(),
1898-
},
1899-
AggregatedAttestation {
1900-
aggregation_bits: bits_b.clone(),
1901-
data: data_b.clone(),
1902-
},
1903-
];
1904-
let proofs = vec![
1905-
AggregatedSignatureProof::empty(bits_a),
1906-
AggregatedSignatureProof::empty(bits_b),
1903+
let entries = vec![
1904+
(
1905+
AggregatedAttestation {
1906+
aggregation_bits: bits_a.clone(),
1907+
data: data_a.clone(),
1908+
},
1909+
AggregatedSignatureProof::empty(bits_a),
1910+
),
1911+
(
1912+
AggregatedAttestation {
1913+
aggregation_bits: bits_b.clone(),
1914+
data: data_b.clone(),
1915+
},
1916+
AggregatedSignatureProof::empty(bits_b),
1917+
),
19071918
];
19081919

19091920
let state = State::from_genesis(1000, vec![]);
1910-
let (out_atts, out_proofs) =
1911-
compact_attestations(atts.clone(), proofs.clone(), &state).unwrap();
1912-
assert_eq!(out_atts.len(), 2);
1913-
assert_eq!(out_proofs.len(), 2);
1914-
assert_eq!(out_atts[0].data, data_a);
1915-
assert_eq!(out_atts[1].data, data_b);
1921+
let out = compact_attestations(entries, &state).unwrap();
1922+
assert_eq!(out.len(), 2);
1923+
assert_eq!(out[0].0.data, data_a);
1924+
assert_eq!(out[1].0.data, data_b);
19161925
}
19171926

19181927
#[test]
@@ -1925,32 +1934,36 @@ mod tests {
19251934
let bits_1 = make_bits(&[1]);
19261935
let bits_2 = make_bits(&[2]);
19271936

1928-
let atts = vec![
1929-
AggregatedAttestation {
1930-
aggregation_bits: bits_0.clone(),
1931-
data: data_a.clone(),
1932-
},
1933-
AggregatedAttestation {
1934-
aggregation_bits: bits_1.clone(),
1935-
data: data_b.clone(),
1936-
},
1937-
AggregatedAttestation {
1938-
aggregation_bits: bits_2.clone(),
1939-
data: data_c.clone(),
1940-
},
1941-
];
1942-
let proofs = vec![
1943-
AggregatedSignatureProof::empty(bits_0),
1944-
AggregatedSignatureProof::empty(bits_1),
1945-
AggregatedSignatureProof::empty(bits_2),
1937+
let entries = vec![
1938+
(
1939+
AggregatedAttestation {
1940+
aggregation_bits: bits_0.clone(),
1941+
data: data_a.clone(),
1942+
},
1943+
AggregatedSignatureProof::empty(bits_0),
1944+
),
1945+
(
1946+
AggregatedAttestation {
1947+
aggregation_bits: bits_1.clone(),
1948+
data: data_b.clone(),
1949+
},
1950+
AggregatedSignatureProof::empty(bits_1),
1951+
),
1952+
(
1953+
AggregatedAttestation {
1954+
aggregation_bits: bits_2.clone(),
1955+
data: data_c.clone(),
1956+
},
1957+
AggregatedSignatureProof::empty(bits_2),
1958+
),
19461959
];
19471960

19481961
let state = State::from_genesis(1000, vec![]);
1949-
let (out_atts, _) = compact_attestations(atts, proofs, &state).unwrap();
1950-
assert_eq!(out_atts.len(), 3);
1951-
assert_eq!(out_atts[0].data, data_a);
1952-
assert_eq!(out_atts[1].data, data_b);
1953-
assert_eq!(out_atts[2].data, data_c);
1962+
let out = compact_attestations(entries, &state).unwrap();
1963+
assert_eq!(out.len(), 3);
1964+
assert_eq!(out[0].0.data, data_a);
1965+
assert_eq!(out[1].0.data, data_b);
1966+
assert_eq!(out[2].0.data, data_c);
19541967
}
19551968

19561969
#[test]
@@ -2036,4 +2049,65 @@ mod tests {
20362049
"Expected DuplicateAttestationData, got: {result:?}"
20372050
);
20382051
}
2052+
2053+
/// A partially-overlapping proof is still selected as long as it adds at
2054+
/// least one previously-uncovered validator. The greedy prefers the
2055+
/// largest proof first, then picks additional proofs whose coverage
2056+
/// extends `covered`. The resulting overlap is handled downstream by
2057+
/// `aggregate_proofs` → `xmss_aggregate` (which tracks duplicate pubkeys
2058+
/// across children via its `dup_pub_keys` machinery).
2059+
#[test]
2060+
fn extend_proofs_greedily_allows_overlap_when_it_adds_coverage() {
2061+
let data = make_att_data(1);
2062+
2063+
// Distinct sizes to avoid tie-breaking ambiguity (HashSet iteration
2064+
// order differs between debug/release):
2065+
// A = {0, 1, 2, 3} (4 validators — largest, picked first)
2066+
// B = {2, 3, 4} (overlaps A on {2,3} but adds validator 4)
2067+
// C = {1, 2} (subset of A — adds nothing, must be skipped)
2068+
let proof_a = AggregatedSignatureProof::empty(make_bits(&[0, 1, 2, 3]));
2069+
let proof_b = AggregatedSignatureProof::empty(make_bits(&[2, 3, 4]));
2070+
let proof_c = AggregatedSignatureProof::empty(make_bits(&[1, 2]));
2071+
2072+
let mut selected = Vec::new();
2073+
extend_proofs_greedily(&[proof_a, proof_b, proof_c], &mut selected, &data);
2074+
2075+
assert_eq!(
2076+
selected.len(),
2077+
2,
2078+
"A and B selected (B adds validator 4); C adds nothing and is skipped"
2079+
);
2080+
2081+
let covered: HashSet<u64> = selected
2082+
.iter()
2083+
.flat_map(|(_, p)| p.participant_indices())
2084+
.collect();
2085+
assert_eq!(covered, HashSet::from([0, 1, 2, 3, 4]));
2086+
2087+
// Attestation bits mirror the proof's participants for each entry.
2088+
for (att, proof) in &selected {
2089+
assert_eq!(att.aggregation_bits, proof.participants);
2090+
assert_eq!(att.data, data);
2091+
}
2092+
}
2093+
2094+
/// When no proof contributes new coverage (subset of a previously selected
2095+
/// proof), greedy terminates without selecting it.
2096+
#[test]
2097+
fn extend_proofs_greedily_stops_when_no_new_coverage() {
2098+
let data = make_att_data(1);
2099+
2100+
// B's participants are a subset of A's. After picking A, B offers zero
2101+
// new coverage and must not be selected (its inclusion would also
2102+
// violate the disjoint invariant).
2103+
let proof_a = AggregatedSignatureProof::empty(make_bits(&[0, 1, 2, 3]));
2104+
let proof_b = AggregatedSignatureProof::empty(make_bits(&[1, 2]));
2105+
2106+
let mut selected = Vec::new();
2107+
extend_proofs_greedily(&[proof_a, proof_b], &mut selected, &data);
2108+
2109+
assert_eq!(selected.len(), 1);
2110+
let covered: HashSet<u64> = selected[0].1.participant_indices().collect();
2111+
assert_eq!(covered, HashSet::from([0, 1, 2, 3]));
2112+
}
20392113
}

crates/common/crypto/src/lib.rs

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -144,8 +144,14 @@ pub fn aggregate_mixed(
144144

145145
ensure_prover_ready();
146146

147-
let deserialized = deserialize_children(children)?;
148-
let children_refs = to_children_refs(&deserialized);
147+
// Split deserialized children into parallel Vecs so we can borrow pubkey
148+
// slices (required by xmss_aggregate's tuple type) while moving the large
149+
// AggregatedXMSS values into the children list without cloning. `pks_list`
150+
// must outlive `children_refs`.
151+
let (pks_list, aggs): (Vec<Vec<LeanSigPubKey>>, Vec<AggregatedXMSS>) =
152+
deserialize_children(children)?.into_iter().unzip();
153+
let children_refs: Vec<(&[LeanSigPubKey], AggregatedXMSS)> =
154+
pks_list.iter().map(Vec::as_slice).zip(aggs).collect();
149155

150156
let raw_xmss: Vec<(LeanSigPubKey, LeanSigSignature)> = raw_public_keys
151157
.into_iter()
@@ -178,8 +184,11 @@ pub fn aggregate_proofs(
178184

179185
ensure_prover_ready();
180186

181-
let deserialized = deserialize_children(children)?;
182-
let children_refs = to_children_refs(&deserialized);
187+
// See `aggregate_mixed` for why this unzip-and-rezip dance is needed.
188+
let (pks_list, aggs): (Vec<Vec<LeanSigPubKey>>, Vec<AggregatedXMSS>) =
189+
deserialize_children(children)?.into_iter().unzip();
190+
let children_refs: Vec<(&[LeanSigPubKey], AggregatedXMSS)> =
191+
pks_list.iter().map(Vec::as_slice).zip(aggs).collect();
183192

184193
let (_sorted_pubkeys, aggregate) = xmss_aggregate(&children_refs, vec![], &message.0, slot, 2);
185194

@@ -204,16 +213,6 @@ fn deserialize_children(
204213
.collect()
205214
}
206215

207-
/// Build the reference slice that `xmss_aggregate` expects.
208-
fn to_children_refs(
209-
deserialized: &[(Vec<LeanSigPubKey>, AggregatedXMSS)],
210-
) -> Vec<(&[LeanSigPubKey], AggregatedXMSS)> {
211-
deserialized
212-
.iter()
213-
.map(|(pks, agg)| (pks.as_slice(), agg.clone()))
214-
.collect()
215-
}
216-
217216
/// Serialize an `AggregatedXMSS` into the `ByteListMiB` wire format.
218217
fn serialize_aggregate(aggregate: AggregatedXMSS) -> Result<ByteListMiB, AggregationError> {
219218
let serialized = aggregate.serialize();

0 commit comments

Comments
 (0)