Skip to content
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
56 commits
Select commit Hold shift + click to select a range
50118d4
added tests for calculating generalized indices
fernantho Oct 15, 2025
e77e465
added first version of GI calculation walking the specified path with…
fernantho Oct 16, 2025
e00c804
refactored code. Detached PathElement processing, currently done at t…
fernantho Oct 17, 2025
83596c5
added an updateRoot function with the GI formula. more refactoring
fernantho Oct 17, 2025
787bb13
added changelog
fernantho Oct 17, 2025
253c1b6
replaced TODO tag
fernantho Oct 17, 2025
ed62201
udpated some comments
fernantho Oct 17, 2025
a2154e3
simplified code - removed duplicated code in processingLengthField fu…
fernantho Oct 17, 2025
62646ff
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
fernantho Oct 17, 2025
fe4d7fe
run gazelle
fernantho Oct 17, 2025
96f1c4d
merging all input path processing into path.go
fernantho Oct 20, 2025
9e0314e
reviewed Jun's feedback
fernantho Oct 20, 2025
a1de521
removed unnecessary idx pointer var + fixed error with length data ty…
fernantho Oct 20, 2025
24a1fff
refactored path.go after merging path elements from generalized_indic…
fernantho Oct 20, 2025
43835e3
re-computed GIs for tests as VariableTestContainer added a new field.
fernantho Oct 20, 2025
eb7637c
added minor comment - rawPath MUST be snake case
fernantho Oct 20, 2025
1baa32a
fixed vector GI calculation - updated tests GIs
fernantho Oct 20, 2025
d5b1227
removed updateRoot function in favor of inline code
fernantho Oct 20, 2025
e9741e4
path input data enforced to be snake case
fernantho Oct 21, 2025
73e3ee7
added sanity checks for accessing outbound element indices - checked …
fernantho Oct 21, 2025
b65fff9
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
fernantho Oct 21, 2025
d800a18
fixed issues triggered after merging develop
fernantho Oct 21, 2025
b276b68
Removed redundant comment
fernantho Oct 21, 2025
e0c8878
removed unreachable condition as `strings.Split` always return a slic…
fernantho Oct 21, 2025
9dfa152
added tests to cover edge cases + cleaned code (toLower is no longer …
fernantho Oct 21, 2025
6478e00
added Jun's feedback + more testing
fernantho Oct 21, 2025
5eac34f
postponed snake case conversion to do it on a per-element-basis. Adde…
fernantho Oct 21, 2025
f3a1b4f
addressed several Jun's comments.
fernantho Oct 21, 2025
2718dc9
added sanity check to prevent length of a multi-dimensional array. ad…
fernantho Oct 21, 2025
1268a2c
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
fernantho Oct 21, 2025
349a993
Update encoding/ssz/query/generalized_index.go
fernantho Oct 22, 2025
f2cc4ac
Update encoding/ssz/query/generalized_index.go
fernantho Oct 22, 2025
7977386
Update encoding/ssz/query/generalized_index.go
fernantho Oct 22, 2025
f03dade
placed constant bitsPerChunk in the right place. Exported BitsPerChun…
fernantho Oct 22, 2025
c317936
added helpers for computing GI of each data type
fernantho Oct 22, 2025
0972263
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
fernantho Oct 22, 2025
ad3dc9f
changed %q in favor of %s
fernantho Oct 22, 2025
4293063
Update encoding/ssz/query/path.go
fernantho Oct 22, 2025
0303b90
removed the least restrictive condition isBasicType
fernantho Oct 23, 2025
57bb141
replaced length of containerInfo.order for containerInfo.fields for c…
fernantho Oct 23, 2025
b6505eb
removed outdated comment
fernantho Oct 23, 2025
7a808ca
removed toSnakeCase conversion.
fernantho Oct 23, 2025
da278ed
moved isBasicType func to its natural place, SSZType
fernantho Oct 23, 2025
f3f9a60
cosmetic refactor
fernantho Oct 23, 2025
0b05112
cleaned tests
fernantho Oct 23, 2025
6485eb7
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
fernantho Oct 23, 2025
385650d
renamed "root" to "index"
fernantho Oct 23, 2025
83284c7
removed unnecessary check for negative integers. Replaced %q for %s.
fernantho Oct 23, 2025
900f971
refactored regex variables and prevented re-assignation
fernantho Oct 23, 2025
2c8885b
added length regex explanation
fernantho Oct 24, 2025
212d31b
added more testing for stressing regex for path processing
fernantho Oct 24, 2025
4aa7b82
renamed currentIndex to parentIndex for clarity and documented the re…
fernantho Oct 24, 2025
9d850e6
Update encoding/ssz/query/generalized_index.go
fernantho Oct 24, 2025
30fc2a7
Merge branch 'develop' into feat/ssz-ql-parse-path-to-generalized-index
rkapka Oct 27, 2025
c61cdf7
run gazelle
fernantho Oct 27, 2025
6107655
fixed never asserted error. Updated error message
fernantho Oct 27, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changelog/fernantho_ssz-ql-calculate-generalized-indices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- Added GeneralizedIndicesFromPath function to calculate the GIs for a given sszInfo object and a PathElement
25 changes: 14 additions & 11 deletions encoding/ssz/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import (
"github.com/prysmaticlabs/go-bitfield"
)

const bytesPerChunk = 32
const (
BitsPerChunk = 256
BytesPerChunk = 32
)

// BitlistRoot returns the mix in length of a bitwise Merkleized bitfield.
func BitlistRoot(bfield bitfield.Bitfield, maxCapacity uint64) ([32]byte, error) {
Expand Down Expand Up @@ -54,14 +57,14 @@ func BitwiseMerkleize(chunks [][32]byte, count, limit uint64) ([32]byte, error)
}

// PackByChunk a given byte array's final chunk with zeroes if needed.
func PackByChunk(serializedItems [][]byte) ([][bytesPerChunk]byte, error) {
var emptyChunk [bytesPerChunk]byte
func PackByChunk(serializedItems [][]byte) ([][BytesPerChunk]byte, error) {
var emptyChunk [BytesPerChunk]byte
// If there are no items, we return an empty chunk.
if len(serializedItems) == 0 {
return [][bytesPerChunk]byte{emptyChunk}, nil
} else if len(serializedItems[0]) == bytesPerChunk {
return [][BytesPerChunk]byte{emptyChunk}, nil
} else if len(serializedItems[0]) == BytesPerChunk {
// If each item has exactly BYTES_PER_CHUNK length, we return the list of serialized items.
chunks := make([][bytesPerChunk]byte, 0, len(serializedItems))
chunks := make([][BytesPerChunk]byte, 0, len(serializedItems))
for _, c := range serializedItems {
chunks = append(chunks, bytesutil.ToBytes32(c))
}
Expand All @@ -75,12 +78,12 @@ func PackByChunk(serializedItems [][]byte) ([][bytesPerChunk]byte, error) {
// If all our serialized item slices are length zero, we
// exit early.
if len(orderedItems) == 0 {
return [][bytesPerChunk]byte{emptyChunk}, nil
return [][BytesPerChunk]byte{emptyChunk}, nil
}
numItems := len(orderedItems)
var chunks [][bytesPerChunk]byte
for i := 0; i < numItems; i += bytesPerChunk {
j := i + bytesPerChunk
var chunks [][BytesPerChunk]byte
for i := 0; i < numItems; i += BytesPerChunk {
j := i + BytesPerChunk
// We create our upper bound index of the chunk, if it is greater than numItems,
// we set it as numItems itself.
if j > numItems {
Expand All @@ -89,7 +92,7 @@ func PackByChunk(serializedItems [][]byte) ([][bytesPerChunk]byte, error) {
// We create chunks from the list of items based on the
// indices determined above.
// Right-pad the last chunk with zero bytes if it does not
// have length bytesPerChunk from the helper.
// have length BytesPerChunk from the helper.
// The ToBytes32 helper allocates a 32-byte array, before
// copying the ordered items in. This ensures that even if
// the last chunk is != 32 in length, we will right-pad it with
Expand Down
2 changes: 2 additions & 0 deletions encoding/ssz/query/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ go_library(
"bitlist.go",
"bitvector.go",
"container.go",
"generalized_index.go",
"list.go",
"path.go",
"query.go",
Expand All @@ -24,6 +25,7 @@ go_library(
go_test(
name = "go_default_test",
srcs = [
"generalized_index_test.go",
"path_test.go",
"query_test.go",
"tag_parser_test.go",
Expand Down
306 changes: 306 additions & 0 deletions encoding/ssz/query/generalized_index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
package query

import (
"errors"
"fmt"

"github.com/OffchainLabs/prysm/v6/encoding/ssz"
)

const listBaseIndex = 2

// GetGeneralizedIndexFromPath calculates the generalized index for a given path.
// To calculate the generalized index, two inputs are needed:
// 1. The sszInfo of the root object, to be able to navigate the SSZ structure
// 2. The path to the field (e.g., "field_a.field_b[3].field_c")
// It walks the path step by step, updating the generalized index at each step.
func GetGeneralizedIndexFromPath(info *SszInfo, path []PathElement) (uint64, error) {
if info == nil {
return 0, errors.New("SszInfo is nil")
}

// If path is empty, no generalized index can be computed.
if len(path) == 0 {
return 0, errors.New("cannot compute generalized index for an empty path")
}

// Starting from the root generalized index
root := uint64(1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you rename it to something like currentIndex? It's a bit odd to call it root since it's not a root of anything

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure!
I got the inspiration for the root name from spec:

def get_generalized_index(typ: SSZType, *path: PyUnion[int, SSZVariableName]) -> GeneralizedIndex:
    """
    Converts a path (eg. `[7, "foo", 3]` for `x[7].foo[3]`, `[12, "bar", "__len__"]` for
    `len(x[12].bar)`) into the generalized index representing its position in the Merkle tree.
    """
    root = GeneralizedIndex(1)
   (...)

But I do not like it because I do not associate it to an index.

currentInfo := info

for _, pathElement := range path {
element := pathElement

// Check that we are in a container to access fields
if currentInfo.sszType != Container {
return 0, fmt.Errorf("indexing requires a container field step first, got %s", currentInfo.sszType)
}

// Retrieve the field position and SSZInfo for the field in the current container
fieldPos, fieldSsz, err := getContainerFieldByName(currentInfo, element.Name)
if err != nil {
return 0, fmt.Errorf("container field %s not found: %w", element.Name, err)
}

// Get the chunk count for the current container
chunkCount, err := getChunkCount(currentInfo)
if err != nil {
return 0, fmt.Errorf("chunk count error: %w", err)
}

// Update the generalized index to point to the specified field
root = root*nextPowerOfTwo(chunkCount) + fieldPos
currentInfo = fieldSsz

// Check if a path element is a length field
if element.Length {
currentInfo, root, err = calculateLengthGeneralizedIndex(fieldSsz, element, root)
if err != nil {
return 0, fmt.Errorf("length calculation error: %w", err)
}
continue
}

if element.Index == nil {
continue
}

switch fieldSsz.sszType {
case List:
currentInfo, root, err = calculateListGeneralizedIndex(fieldSsz, element, root)
if err != nil {
return 0, fmt.Errorf("list calculation error: %w", err)
}

case Vector:
currentInfo, root, err = calculateVectorGeneralizedIndex(fieldSsz, element, root)
if err != nil {
return 0, fmt.Errorf("vector calculation error: %w", err)
}

case Bitlist:
currentInfo, root, err = calculateBitlistGeneralizedIndex(fieldSsz, element, root)
if err != nil {
return 0, fmt.Errorf("bitlist calculation error: %w", err)
}

case Bitvector:
currentInfo, root, err = calculateBitvectorGeneralizedIndex(fieldSsz, element, root)
if err != nil {
return 0, fmt.Errorf("bitvector calculation error: %w", err)
}

default:
return 0, fmt.Errorf("indexing not supported for type %s", fieldSsz.sszType)
}

}

return root, nil
}

// getContainerFieldByName finds a container field by its name
// and returns its index and SSZInfo.
func getContainerFieldByName(info *SszInfo, fieldName string) (uint64, *SszInfo, error) {
containerInfo, err := info.ContainerInfo()
if err != nil {
return 0, nil, err
}

for index, name := range containerInfo.order {
if name == fieldName {
fieldInfo := containerInfo.fields[name]
if fieldInfo == nil || fieldInfo.sszInfo == nil {
return 0, nil, fmt.Errorf("field %s has no ssz info", name)
}
return uint64(index), fieldInfo.sszInfo, nil
}
}

return 0, nil, fmt.Errorf("field %s not found", fieldName)
}

// Helpers for Generalized Index calculation per type

// calculateLengthGeneralizedIndex calculates the generalized index for a length field.
// note: length fields are only valid for List and Bitlist types. Multi-dimensional arrays are not supported.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be supported for Vector and Bitvector too?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In relation to this, I also followed the spec algo:

    for p in path:
        # If we descend to a basic type, the path cannot continue further
        assert not issubclass(typ, BasicValue)
        if p == "__len__":
+            assert issubclass(typ, (List, ByteList))
            typ = uint64
            root = GeneralizedIndex(root * 2 + 1)

To my understanding, there is no length field for Vector and Bitvector as they have fixed size determined by their type.

func calculateLengthGeneralizedIndex(fieldSsz *SszInfo, element PathElement, root uint64) (*SszInfo, uint64, error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to the comment above (changing root to currentIndex), can you rename the root param to something like parentIndex (or something else more suitable)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-renamed them 😅
changed all of them from root to currentIndex. But now I got to this comment and, for these functions, I like this parentIndex more than currentIndex.

if element.Index != nil {
return nil, 0, fmt.Errorf("len() is not supported for indexed elements (multi-dimensional arrays)")
}
// Length field is only valid for List and Bitlist types
if fieldSsz.sszType != List && fieldSsz.sszType != Bitlist {
return nil, 0, fmt.Errorf("len() is only supported for List and Bitlist types, got %s", fieldSsz.sszType)
}
// Length is a uint64 per SSZ spec
currentInfo := &SszInfo{sszType: Uint64}
lengthRoot := root*2 + 1
return currentInfo, lengthRoot, nil
}

// calculateListGeneralizedIndex calculates the generalized index for a list element.
func calculateListGeneralizedIndex(fieldSsz *SszInfo, element PathElement, root uint64) (*SszInfo, uint64, error) {
li, err := fieldSsz.ListInfo()
if err != nil {
return nil, 0, fmt.Errorf("list info error: %w", err)
}
elem, err := li.Element()
if err != nil {
return nil, 0, fmt.Errorf("list element error: %w", err)
}
if *element.Index >= li.Limit() {
return nil, 0, fmt.Errorf("index %d out of bounds for list with limit %d", *element.Index, li.Limit())
}
// Compute chunk position for the element
var chunkPos uint64
if elem.sszType.isBasic() {
start := *element.Index * itemLength(elem)
chunkPos = start / ssz.BytesPerChunk
} else {
chunkPos = *element.Index
}
innerChunkCount, err := getChunkCount(fieldSsz)
if err != nil {
return nil, 0, fmt.Errorf("chunk count error: %w", err)
}
// root = root * base_index * pow2ceil(chunk_count(container)) + fieldPos
listRoot := root*listBaseIndex*nextPowerOfTwo(innerChunkCount) + chunkPos
currentInfo := elem

return currentInfo, listRoot, nil
}

// calculateVectorGeneralizedIndex calculates the generalized index for a vector element.
func calculateVectorGeneralizedIndex(fieldSsz *SszInfo, element PathElement, root uint64) (*SszInfo, uint64, error) {
vi, err := fieldSsz.VectorInfo()
if err != nil {
return nil, 0, fmt.Errorf("vector info error: %w", err)
}
elem, err := vi.Element()
if err != nil {
return nil, 0, fmt.Errorf("vector element error: %w", err)
}
if *element.Index >= vi.Length() {
return nil, 0, fmt.Errorf("index %d out of bounds for vector with length %d", *element.Index, vi.Length())
}
var chunkPos uint64
if elem.sszType.isBasic() {
start := *element.Index * itemLength(elem)
chunkPos = start / ssz.BytesPerChunk
} else {
chunkPos = *element.Index
}
innerChunkCount, err := getChunkCount(fieldSsz)
if err != nil {
return nil, 0, fmt.Errorf("chunk count error: %w", err)
}
vectorRoot := root*nextPowerOfTwo(innerChunkCount) + chunkPos

currentInfo := elem
return currentInfo, vectorRoot, nil
}

// calculateBitlistGeneralizedIndex calculates the generalized index for a bitlist element.
func calculateBitlistGeneralizedIndex(fieldSsz *SszInfo, element PathElement, root uint64) (*SszInfo, uint64, error) {
// Bits packed into 256-bit chunks; select the chunk containing the bit
chunkPos := *element.Index / ssz.BitsPerChunk
innerChunkCount, err := getChunkCount(fieldSsz)
if err != nil {
return nil, 0, fmt.Errorf("chunk count error: %w", err)
}
bitlistRoot := root*listBaseIndex*nextPowerOfTwo(innerChunkCount) + chunkPos

// Bits element is not further descendable; set to basic to guard further steps
currentInfo := &SszInfo{sszType: Boolean}
return currentInfo, bitlistRoot, nil
}

// calculateBitvectorGeneralizedIndex calculates the generalized index for a bitvector element.
func calculateBitvectorGeneralizedIndex(fieldSsz *SszInfo, element PathElement, root uint64) (*SszInfo, uint64, error) {
chunkPos := *element.Index / ssz.BitsPerChunk
innerChunkCount, err := getChunkCount(fieldSsz)
if err != nil {
return nil, 0, fmt.Errorf("chunk count error: %w", err)
}
bitvectorRoot := root*nextPowerOfTwo(innerChunkCount) + chunkPos

// Bits element is not further descendable; set to basic to guard further steps
currentInfo := &SszInfo{sszType: Boolean}
return currentInfo, bitvectorRoot, nil
}

// Helper functions from SSZ spec

// itemLength calculates the byte length of an SSZ item based on its type information.
// For basic SSZ types (uint8, uint16, uint32, uint64, bool, etc.), it returns the actual
// size of the type in bytes. For compound types (containers, lists, vectors), it returns
// BytesPerChunk which represents the standard SSZ chunk size (32 bytes) used for
// Merkle tree operations in the SSZ serialization format.
func itemLength(info *SszInfo) uint64 {
if info.sszType.isBasic() {
return info.Size()
}
return ssz.BytesPerChunk
}

// nextPowerOfTwo computes the next power of two greater than or equal to v.
func nextPowerOfTwo(v uint64) uint64 {
v--
v |= v >> 1
v |= v >> 2
v |= v >> 4
v |= v >> 8
v |= v >> 16
v++
return uint64(v)
}

// getChunkCount returns the number of chunks for the given SSZInfo (equivalent to chunk_count in the spec)
func getChunkCount(info *SszInfo) (uint64, error) {
switch info.sszType {
case Uint8, Uint16, Uint32, Uint64, Boolean:
return 1, nil
case Container:
containerInfo, err := info.ContainerInfo()
if err != nil {
return 0, err
}
return uint64(len(containerInfo.fields)), nil
case List:
listInfo, err := info.ListInfo()
if err != nil {
return 0, err
}
elementInfo, err := listInfo.Element()
if err != nil {
return 0, err
}
elemLength := itemLength(elementInfo)
return (listInfo.Limit()*elemLength + 31) / ssz.BytesPerChunk, nil
case Vector:
vectorInfo, err := info.VectorInfo()
if err != nil {
return 0, err
}
elementInfo, err := vectorInfo.Element()
if err != nil {
return 0, err
}
elemLength := itemLength(elementInfo)
return (vectorInfo.Length()*elemLength + 31) / ssz.BytesPerChunk, nil
case Bitlist:
bitlistInfo, err := info.BitlistInfo()
if err != nil {
return 0, err
}
return (bitlistInfo.Limit() + 255) / ssz.BitsPerChunk, nil // Bits are packed into 256-bit chunks
case Bitvector:
bitvectorInfo, err := info.BitvectorInfo()
if err != nil {
return 0, err
}
return (bitvectorInfo.Length() + 255) / ssz.BitsPerChunk, nil // Bits are packed into 256-bit chunks
default:
return 0, errors.New("unsupported SSZ type for chunk count calculation")
}
}
Loading