Summary
The dynssz-size struct tag on SyncCommitteeContribution.AggregationBits hardcodes the value of SYNC_COMMITTEE_SUBNET_COUNT as the literal 4 instead of referencing the spec constant by name. This makes the dynamic-SSZ size of the field independent of the actual runtime spec, so under any preset where SYNC_COMMITTEE_SUBNET_COUNT != 4 the field is sized incorrectly.
Location
spec/altair/synccommitteecontribution.go:36
// AggregationBits size is SYNC_COMMITTEE_SIZE // SYNC_COMMITTEE_SUBNET_COUNT
AggregationBits bitfield.Bitvector128 `dynssz-size:"SYNC_COMMITTEE_SIZE/4/8" ssz-size:"16"`
The comment on the line directly above states the intended size is SYNC_COMMITTEE_SIZE // SYNC_COMMITTEE_SUBNET_COUNT, but the tag substitutes the literal 4 for SYNC_COMMITTEE_SUBNET_COUNT.
Why this looks wrong
Every other dynssz-size expression in the tree derives its size from named spec constants — this is the one place a magic number appears in the divisor:
| File |
Field |
dynssz-size |
spec/electra/attestation.go |
CommitteeBits |
MAX_COMMITTEES_PER_SLOT/8 |
spec/altair/syncaggregate.go |
SyncCommitteeBits |
SYNC_COMMITTEE_SIZE/8 |
spec/altair/synccommitteecontribution.go |
AggregationBits |
SYNC_COMMITTEE_SIZE/**4**/8 |
The whole point of dynssz-size (versus the static ssz-size) is to let the encoder recompute field sizes from the runtime config when a non-standard preset is loaded (http.WithCustomSpecSupport(true)). Baking in 4 defeats that for this field: the dynamic path will keep computing SYNC_COMMITTEE_SIZE / 4 / 8 regardless of what SYNC_COMMITTEE_SUBNET_COUNT actually is in the loaded spec.
Impact
SyncCommitteeContribution.AggregationBits is a bitvector of SYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNT bits. Under a custom preset that changes SYNC_COMMITTEE_SUBNET_COUNT away from 4, the dynamic-SSZ size for this field is wrong, which corrupts marshal/unmarshal and HashTreeRoot for SyncCommitteeContribution (and anything embedding it) on that preset.
This is latent for the standard networks: both the mainnet and minimal presets define SYNC_COMMITTEE_SUBNET_COUNT = 4, so SYNC_COMMITTEE_SIZE / 4 / 8 happens to be correct there, and the consensus-spec test vectors (which run against those two presets) pass. The bug only surfaces on a custom preset with a different subnet count.
Suggested fix
Use the spec constant name in the divisor, matching the sibling fields and the accompanying comment:
AggregationBits bitfield.Bitvector128 `dynssz-size:"SYNC_COMMITTEE_SIZE/SYNC_COMMITTEE_SUBNET_COUNT/8" ssz-size:"16"`
If the tag is generated rather than hand-written, the generator template / source annotation for this field should be updated so the named constant is emitted. If there's a reason the multi-variable expression can't be used here (e.g. a codegen limitation), it would help to capture that in a comment so the literal 4 doesn't read as an oversight.
Summary
The
dynssz-sizestruct tag onSyncCommitteeContribution.AggregationBitshardcodes the value ofSYNC_COMMITTEE_SUBNET_COUNTas the literal4instead of referencing the spec constant by name. This makes the dynamic-SSZ size of the field independent of the actual runtime spec, so under any preset whereSYNC_COMMITTEE_SUBNET_COUNT != 4the field is sized incorrectly.Location
spec/altair/synccommitteecontribution.go:36The comment on the line directly above states the intended size is
SYNC_COMMITTEE_SIZE // SYNC_COMMITTEE_SUBNET_COUNT, but the tag substitutes the literal4forSYNC_COMMITTEE_SUBNET_COUNT.Why this looks wrong
Every other
dynssz-sizeexpression in the tree derives its size from named spec constants — this is the one place a magic number appears in the divisor:dynssz-sizespec/electra/attestation.goCommitteeBitsMAX_COMMITTEES_PER_SLOT/8spec/altair/syncaggregate.goSyncCommitteeBitsSYNC_COMMITTEE_SIZE/8spec/altair/synccommitteecontribution.goAggregationBitsSYNC_COMMITTEE_SIZE/**4**/8The whole point of
dynssz-size(versus the staticssz-size) is to let the encoder recompute field sizes from the runtime config when a non-standard preset is loaded (http.WithCustomSpecSupport(true)). Baking in4defeats that for this field: the dynamic path will keep computingSYNC_COMMITTEE_SIZE / 4 / 8regardless of whatSYNC_COMMITTEE_SUBNET_COUNTactually is in the loaded spec.Impact
SyncCommitteeContribution.AggregationBitsis a bitvector ofSYNC_COMMITTEE_SIZE / SYNC_COMMITTEE_SUBNET_COUNTbits. Under a custom preset that changesSYNC_COMMITTEE_SUBNET_COUNTaway from4, the dynamic-SSZ size for this field is wrong, which corrupts marshal/unmarshal andHashTreeRootforSyncCommitteeContribution(and anything embedding it) on that preset.This is latent for the standard networks: both the mainnet and minimal presets define
SYNC_COMMITTEE_SUBNET_COUNT = 4, soSYNC_COMMITTEE_SIZE / 4 / 8happens to be correct there, and the consensus-spec test vectors (which run against those two presets) pass. The bug only surfaces on a custom preset with a different subnet count.Suggested fix
Use the spec constant name in the divisor, matching the sibling fields and the accompanying comment:
If the tag is generated rather than hand-written, the generator template / source annotation for this field should be updated so the named constant is emitted. If there's a reason the multi-variable expression can't be used here (e.g. a codegen limitation), it would help to capture that in a comment so the literal
4doesn't read as an oversight.