SSZ-QL: Handle List type & Populate the actual value dynamically (#15637)

* Add VariableTestContainer in ssz_query.proto

* Add listInfo

* Use errors.New for making an error with a static string literal

* Add listInfo field when analyzing the List type

* Persist the field order in the container

* Add actualOffset and goFieldName at fieldInfo

* Add PopulateFromValue function & update test runner

* Handle slice of ssz object for marshalling

* Add CalculateOffsetAndLength test

* Add comments for better doc

* Changelog :)

* Apply reviews from Radek

* Remove actualOffset and update offset field instead

* Add Nested container of variable-sized for testing nested path

* Fix offset adding logics: for variable-sized field, always add 4 instead of its fixed size

* Fix multiple import issue

---------

Co-authored-by: Radosław Kapka <rkapka@wp.pl>
This commit is contained in:
Jun Song
2025-09-09 01:50:24 +09:00
committed by GitHub
parent f035da6fc5
commit b1dc5e485d
14 changed files with 1111 additions and 154 deletions

View File

@@ -5,6 +5,7 @@ go_library(
srcs = [
"analyzer.go",
"container.go",
"list.go",
"path.go",
"query.go",
"ssz_info.go",

View File

@@ -1,6 +1,7 @@
package query
import (
"errors"
"fmt"
"reflect"
"strconv"
@@ -30,6 +31,89 @@ func AnalyzeObject(obj any) (*sszInfo, error) {
return info, nil
}
// PopulateVariableLengthInfo populates runtime information for SSZ fields of variable-sized types.
// This function updates the sszInfo structure with actual lengths and offsets that can only
// be determined at runtime for variable-sized items like Lists and variable-sized Container fields.
func PopulateVariableLengthInfo(sszInfo *sszInfo, value any) error {
if sszInfo == nil {
return errors.New("sszInfo is nil")
}
if value == nil {
return errors.New("value is nil")
}
// Short circuit: If the type is fixed-sized, we don't need to fill in the info.
if !sszInfo.isVariable {
return nil
}
switch sszInfo.sszType {
// In List case, we have to set the actual length of the list.
case List:
listInfo, err := sszInfo.ListInfo()
if err != nil {
return fmt.Errorf("could not get list info: %w", err)
}
if listInfo == nil {
return errors.New("listInfo is nil")
}
val := reflect.ValueOf(value)
if val.Kind() != reflect.Slice {
return fmt.Errorf("expected slice for List type, got %v", val.Kind())
}
length := uint64(val.Len())
if err := listInfo.SetLength(length); err != nil {
return fmt.Errorf("could not set list length: %w", err)
}
return nil
// In Container case, we need to recursively populate variable-sized fields.
case Container:
containerInfo, err := sszInfo.ContainerInfo()
if err != nil {
return fmt.Errorf("could not get container info: %w", err)
}
// Dereference first in case value is a pointer.
derefValue := dereferencePointer(value)
// Start with the fixed size of this Container.
currentOffset := sszInfo.FixedSize()
for _, fieldName := range containerInfo.order {
fieldInfo := containerInfo.fields[fieldName]
childSszInfo := fieldInfo.sszInfo
if childSszInfo == nil {
return fmt.Errorf("sszInfo is nil for field %s", fieldName)
}
// Skip fixed-size fields.
if !childSszInfo.isVariable {
continue
}
// Set the actual offset for variable-sized fields.
fieldInfo.offset = currentOffset
// Recursively populate variable-sized fields.
fieldValue := derefValue.FieldByName(fieldInfo.goFieldName)
if err := PopulateVariableLengthInfo(childSszInfo, fieldValue.Interface()); err != nil {
return fmt.Errorf("could not populate from value for field %s: %w", fieldName, err)
}
currentOffset += childSszInfo.Size()
}
return nil
default:
return fmt.Errorf("unsupported SSZ type (%s) for variable size info", sszInfo.sszType)
}
}
// analyzeType is an entry point that inspects a reflect.Type and computes its SSZ layout information.
func analyzeType(typ reflect.Type, tag *reflect.StructTag) (*sszInfo, error) {
switch typ.Kind() {
@@ -93,7 +177,7 @@ func analyzeHomogeneousColType(typ reflect.Type, tag *reflect.StructTag) (*sszIn
}
if tag == nil {
return nil, fmt.Errorf("tag is required for slice types")
return nil, errors.New("tag is required for slice types")
}
elementInfo, err := analyzeType(typ.Elem(), nil)
@@ -135,7 +219,7 @@ func analyzeHomogeneousColType(typ reflect.Type, tag *reflect.StructTag) (*sszIn
// analyzeListType analyzes SSZ List type and returns its SSZ info.
func analyzeListType(typ reflect.Type, elementInfo *sszInfo, limit uint64) (*sszInfo, error) {
if elementInfo == nil {
return nil, fmt.Errorf("element info is required for List")
return nil, errors.New("element info is required for List")
}
return &sszInfo{
@@ -144,13 +228,18 @@ func analyzeListType(typ reflect.Type, elementInfo *sszInfo, limit uint64) (*ssz
fixedSize: offsetBytes,
isVariable: true,
listInfo: &listInfo{
limit: limit,
element: elementInfo,
},
}, nil
}
// analyzeVectorType analyzes SSZ Vector type and returns its SSZ info.
func analyzeVectorType(typ reflect.Type, elementInfo *sszInfo, length uint64) (*sszInfo, error) {
if elementInfo == nil {
return nil, fmt.Errorf("element info is required for Vector")
return nil, errors.New("element info is required for Vector")
}
return &sszInfo{
@@ -168,11 +257,12 @@ func analyzeContainerType(typ reflect.Type) (*sszInfo, error) {
return nil, fmt.Errorf("can only analyze struct types, got %v", typ.Kind())
}
fields := make(map[string]*fieldInfo)
order := make([]string, 0, typ.NumField())
sszInfo := &sszInfo{
sszType: Container,
typ: typ,
containerInfo: make(map[string]*fieldInfo),
}
var currentOffset uint64
@@ -204,23 +294,31 @@ func analyzeContainerType(typ reflect.Type) (*sszInfo, error) {
return nil, fmt.Errorf("could not analyze type for field %s: %w", fieldName, err)
}
// If one of the fields is variable-sized,
// the entire struct is considered variable-sized.
if info.isVariable {
sszInfo.isVariable = true
}
// Store nested struct info.
sszInfo.containerInfo[fieldName] = &fieldInfo{
sszInfo: info,
offset: currentOffset,
fields[fieldName] = &fieldInfo{
sszInfo: info,
offset: currentOffset,
goFieldName: field.Name,
}
// Persist order
order = append(order, fieldName)
// Update the current offset based on the field's fixed size.
currentOffset += info.fixedSize
// Update the current offset depending on whether the field is variable-sized.
if info.isVariable {
// If one of the fields is variable-sized,
// the entire struct is considered variable-sized.
sszInfo.isVariable = true
currentOffset += offsetBytes
} else {
currentOffset += info.fixedSize
}
}
sszInfo.fixedSize = currentOffset
sszInfo.containerInfo = &containerInfo{
fields: fields,
order: order,
}
return sszInfo, nil
}

View File

@@ -1,11 +1,18 @@
package query
// containerInfo maps a field's JSON name to its sszInfo for nested Containers.
type containerInfo = map[string]*fieldInfo
// containerInfo has
// 1. fields: a field map that maps a field's JSON name to its sszInfo for nested Containers
// 2. order: a list of field names in the order they should be serialized
type containerInfo struct {
fields map[string]*fieldInfo
order []string
}
type fieldInfo struct {
// sszInfo contains the SSZ information of the field.
sszInfo *sszInfo
// offset is the offset of the field within the parent struct.
offset uint64
// goFieldName is the name of the field in Go struct.
goFieldName string
}

View File

@@ -0,0 +1,53 @@
package query
import (
"errors"
"fmt"
)
// listInfo holds information about a SSZ List type.
//
// length is initialized with zero,
// and can be set using SetLength while populating the actual SSZ List.
type listInfo struct {
// limit is the maximum number of elements in the list.
limit uint64
// element is the SSZ info of the list's element type.
element *sszInfo
// length is the actual number of elements at runtime (0 if not set).
length uint64
}
func (l *listInfo) Limit() uint64 {
if l == nil {
return 0
}
return l.limit
}
func (l *listInfo) Element() (*sszInfo, error) {
if l == nil {
return nil, errors.New("listInfo is nil")
}
return l.element, nil
}
func (l *listInfo) Length() uint64 {
if l == nil {
return 0
}
return l.length
}
func (l *listInfo) SetLength(length uint64) error {
if l == nil {
return errors.New("listInfo is nil")
}
if length > l.limit {
return fmt.Errorf("length %d exceeds limit %d", length, l.limit)
}
l.length = length
return nil
}

View File

@@ -1,37 +1,38 @@
package query
import "fmt"
import (
"errors"
"fmt"
)
// CalculateOffsetAndLength calculates the offset and length of a given path within the SSZ object.
// By walking the given path, it accumulates the offsets based on sszInfo.
func CalculateOffsetAndLength(sszInfo *sszInfo, path []PathElement) (*sszInfo, uint64, uint64, error) {
if sszInfo == nil {
return nil, 0, 0, fmt.Errorf("sszInfo is nil")
return nil, 0, 0, errors.New("sszInfo is nil")
}
if len(path) == 0 {
return nil, 0, 0, fmt.Errorf("path is empty")
return nil, 0, 0, errors.New("path is empty")
}
walk := sszInfo
currentOffset := uint64(0)
offset := uint64(0)
for _, elem := range path {
fieldInfos, err := walk.ContainerInfo()
containerInfo, err := walk.ContainerInfo()
if err != nil {
return nil, 0, 0, fmt.Errorf("could not get field infos: %w", err)
}
fieldInfo, exists := fieldInfos[elem.Name]
fieldInfo, exists := containerInfo.fields[elem.Name]
if !exists {
return nil, 0, 0, fmt.Errorf("field %s not found in fieldInfos", elem.Name)
return nil, 0, 0, fmt.Errorf("field %s not found in containerInfo", elem.Name)
}
currentOffset += fieldInfo.offset
offset += fieldInfo.offset
walk = fieldInfo.sszInfo
}
if walk.isVariable {
return nil, 0, 0, fmt.Errorf("cannot calculate length for variable-sized type")
}
return walk, currentOffset, walk.Size(), nil
return walk, offset, walk.Size(), nil
}

View File

@@ -6,100 +6,179 @@ import (
"github.com/OffchainLabs/prysm/v6/encoding/ssz/query"
"github.com/OffchainLabs/prysm/v6/encoding/ssz/query/testutil"
"github.com/OffchainLabs/prysm/v6/proto/ssz_query"
sszquerypb "github.com/OffchainLabs/prysm/v6/proto/ssz_query"
"github.com/OffchainLabs/prysm/v6/testing/require"
)
func TestCalculateOffsetAndLength(t *testing.T) {
tests := []struct {
type testCase struct {
name string
path string
expectedOffset uint64
expectedLength uint64
}{
// Basic integer types
{
name: "field_uint32",
path: ".field_uint32",
expectedOffset: 0,
expectedLength: 4,
},
{
name: "field_uint64",
path: ".field_uint64",
expectedOffset: 4,
expectedLength: 8,
},
// Boolean type
{
name: "field_bool",
path: ".field_bool",
expectedOffset: 12,
expectedLength: 1,
},
// Fixed-size bytes
{
name: "field_bytes32",
path: ".field_bytes32",
expectedOffset: 13,
expectedLength: 32,
},
// Nested container
{
name: "nested container",
path: ".nested",
expectedOffset: 45,
expectedLength: 40,
},
{
name: "nested value1",
path: ".nested.value1",
expectedOffset: 45,
expectedLength: 8,
},
{
name: "nested value2",
path: ".nested.value2",
expectedOffset: 53,
expectedLength: 32,
},
// Vector field
{
name: "vector field",
path: ".vector_field",
expectedOffset: 85,
expectedLength: 192, // 24 * 8 bytes
},
// Trailing field
{
name: "trailing_field",
path: ".trailing_field",
expectedOffset: 277,
expectedLength: 56,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, err := query.ParsePath(tt.path)
require.NoError(t, err)
t.Run("FixedTestContainer", func(t *testing.T) {
tests := []testCase{
// Basic integer types
{
name: "field_uint32",
path: ".field_uint32",
expectedOffset: 0,
expectedLength: 4,
},
{
name: "field_uint64",
path: ".field_uint64",
expectedOffset: 4,
expectedLength: 8,
},
// Boolean type
{
name: "field_bool",
path: ".field_bool",
expectedOffset: 12,
expectedLength: 1,
},
// Fixed-size bytes
{
name: "field_bytes32",
path: ".field_bytes32",
expectedOffset: 13,
expectedLength: 32,
},
// Nested container
{
name: "nested container",
path: ".nested",
expectedOffset: 45,
expectedLength: 40,
},
{
name: "nested value1",
path: ".nested.value1",
expectedOffset: 45,
expectedLength: 8,
},
{
name: "nested value2",
path: ".nested.value2",
expectedOffset: 53,
expectedLength: 32,
},
// Vector field
{
name: "vector field",
path: ".vector_field",
expectedOffset: 85,
expectedLength: 192, // 24 * 8 bytes
},
// Trailing field
{
name: "trailing_field",
path: ".trailing_field",
expectedOffset: 277,
expectedLength: 56,
},
}
info, err := query.AnalyzeObject(&sszquerypb.FixedTestContainer{})
require.NoError(t, err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, err := query.ParsePath(tt.path)
require.NoError(t, err)
_, offset, length, err := query.CalculateOffsetAndLength(info, path)
require.NoError(t, err)
info, err := query.AnalyzeObject(&sszquerypb.FixedTestContainer{})
require.NoError(t, err)
require.Equal(t, tt.expectedOffset, offset, "Expected offset to be %d", tt.expectedOffset)
require.Equal(t, tt.expectedLength, length, "Expected length to be %d", tt.expectedLength)
})
}
_, offset, length, err := query.CalculateOffsetAndLength(info, path)
require.NoError(t, err)
require.Equal(t, tt.expectedOffset, offset, "Expected offset to be %d", tt.expectedOffset)
require.Equal(t, tt.expectedLength, length, "Expected length to be %d", tt.expectedLength)
})
}
})
t.Run("VariableTestContainer", func(t *testing.T) {
tests := []testCase{
// Fixed leading field
{
name: "leading_field",
path: ".leading_field",
expectedOffset: 0,
expectedLength: 32,
},
// Variable-size list fields
{
name: "field_list_uint64",
path: ".field_list_uint64",
expectedOffset: 100, // First part of variable-sized type.
expectedLength: 40, // 5 elements * uint64 (8 bytes each)
},
{
name: "field_list_container",
path: ".field_list_container",
expectedOffset: 140, // Second part of variable-sized type.
expectedLength: 120, // 3 elements * FixedNestedContainer (40 bytes each)
},
// Nested paths
{
name: "nested",
path: ".nested",
expectedOffset: 260,
// Calculated with:
// - Value1: 8 bytes
// - field_list_uint64 offset: 4 bytes
// - field_list_uint64 length: 40 bytes
expectedLength: 52,
},
{
name: "nested.value1",
path: ".nested.value1",
expectedOffset: 260,
expectedLength: 8,
},
{
name: "nested.field_list_uint64",
path: ".nested.field_list_uint64",
expectedOffset: 272,
expectedLength: 40,
},
// Fixed trailing field
{
name: "trailing_field",
path: ".trailing_field",
expectedOffset: 44, // After leading_field + 2 offset pointers
expectedLength: 56,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path, err := query.ParsePath(tt.path)
require.NoError(t, err)
info, err := query.AnalyzeObject(&sszquerypb.VariableTestContainer{})
require.NoError(t, err)
testContainer := createVariableTestContainer()
err = query.PopulateVariableLengthInfo(info, testContainer)
require.NoError(t, err)
_, offset, length, err := query.CalculateOffsetAndLength(info, path)
require.NoError(t, err)
require.Equal(t, tt.expectedOffset, offset, "Expected offset to be %d", tt.expectedOffset)
require.Equal(t, tt.expectedLength, length, "Expected length to be %d", tt.expectedLength)
})
}
})
}
func TestRoundTripSszInfo(t *testing.T) {
specs := []testutil.TestSpec{
getFixedTestContainerSpec(),
getVariableTestContainerSpec(),
}
for _, spec := range specs {
@@ -107,7 +186,7 @@ func TestRoundTripSszInfo(t *testing.T) {
}
}
func createFixedTestContainer() any {
func createFixedTestContainer() *sszquerypb.FixedTestContainer {
fieldBytes32 := make([]byte, 32)
for i := range fieldBytes32 {
fieldBytes32[i] = byte(i + 24)
@@ -123,7 +202,7 @@ func createFixedTestContainer() any {
trailingField[i] = byte(i + 88)
}
return &ssz_query.FixedTestContainer{
return &sszquerypb.FixedTestContainer{
// Basic types
FieldUint32: math.MaxUint32,
FieldUint64: math.MaxUint64,
@@ -147,7 +226,7 @@ func createFixedTestContainer() any {
}
func getFixedTestContainerSpec() testutil.TestSpec {
testContainer := createFixedTestContainer().(*sszquerypb.FixedTestContainer)
testContainer := createFixedTestContainer()
return testutil.TestSpec{
Name: "FixedTestContainer",
@@ -198,3 +277,90 @@ func getFixedTestContainerSpec() testutil.TestSpec {
},
}
}
func createVariableTestContainer() *sszquerypb.VariableTestContainer {
leadingField := make([]byte, 32)
for i := range leadingField {
leadingField[i] = byte(i + 100)
}
trailingField := make([]byte, 56)
for i := range trailingField {
trailingField[i] = byte(i + 150)
}
nestedContainers := make([]*sszquerypb.FixedNestedContainer, 3)
for i := range nestedContainers {
value2 := make([]byte, 32)
for j := range value2 {
value2[j] = byte(j + i*32)
}
nestedContainers[i] = &sszquerypb.FixedNestedContainer{
Value1: uint64(1000 + i),
Value2: value2,
}
}
return &sszquerypb.VariableTestContainer{
// Fixed leading field
LeadingField: leadingField,
// Variable-size lists
FieldListUint64: []uint64{100, 200, 300, 400, 500},
FieldListContainer: nestedContainers,
// Variable nested container
Nested: &sszquerypb.VariableNestedContainer{
Value1: 42,
FieldListUint64: []uint64{1, 2, 3, 4, 5},
},
// Fixed trailing field
TrailingField: trailingField,
}
}
func getVariableTestContainerSpec() testutil.TestSpec {
testContainer := createVariableTestContainer()
return testutil.TestSpec{
Name: "VariableTestContainer",
Type: sszquerypb.VariableTestContainer{},
Instance: testContainer,
PathTests: []testutil.PathTest{
// Fixed leading field
{
Path: ".leading_field",
Expected: testContainer.LeadingField,
},
// Variable-size list of uint64
{
Path: ".field_list_uint64",
Expected: testContainer.FieldListUint64,
},
// Variable-size list of (fixed-size) containers
{
Path: ".field_list_container",
Expected: testContainer.FieldListContainer,
},
// Variable nested container with every path
{
Path: ".nested",
Expected: testContainer.Nested,
},
{
Path: ".nested.value1",
Expected: testContainer.Nested.Value1,
},
{
Path: ".nested.field_list_uint64",
Expected: testContainer.Nested.FieldListUint64,
},
// Fixed trailing field
{
Path: ".trailing_field",
Expected: testContainer.TrailingField,
},
},
}
}

View File

@@ -1,9 +1,9 @@
package query
import (
"errors"
"fmt"
"reflect"
"sort"
"strings"
)
@@ -20,7 +20,10 @@ type sszInfo struct {
fixedSize uint64
// For Container types.
containerInfo containerInfo
containerInfo *containerInfo
// For List types.
listInfo *listInfo
}
func (info *sszInfo) FixedSize() uint64 {
@@ -40,13 +43,33 @@ func (info *sszInfo) Size() uint64 {
return info.fixedSize
}
// NOTE: Handle variable-sized types.
return 0
switch info.sszType {
case List:
length := info.listInfo.length
elementSize := info.listInfo.element.Size()
return length * elementSize
case Container:
size := info.fixedSize
for _, fieldInfo := range info.containerInfo.fields {
if !fieldInfo.sszInfo.isVariable {
continue
}
size += fieldInfo.sszInfo.Size()
}
return size
default:
// NOTE: Handle other variable-sized types.
return 0
}
}
func (info *sszInfo) ContainerInfo() (containerInfo, error) {
func (info *sszInfo) ContainerInfo() (*containerInfo, error) {
if info == nil {
return nil, fmt.Errorf("sszInfo is nil")
return nil, errors.New("sszInfo is nil")
}
if info.sszType != Container {
@@ -54,12 +77,24 @@ func (info *sszInfo) ContainerInfo() (containerInfo, error) {
}
if info.containerInfo == nil {
return nil, fmt.Errorf("sszInfo.containerInfo is nil")
return nil, errors.New("sszInfo.containerInfo is nil")
}
return info.containerInfo, nil
}
func (info *sszInfo) ListInfo() (*listInfo, error) {
if info == nil {
return nil, errors.New("sszInfo is nil")
}
if info.sszType != List {
return nil, fmt.Errorf("sszInfo is not a List type, got %s", info.sszType)
}
return info.listInfo, nil
}
// Print returns a string representation of the sszInfo, which is useful for debugging.
func (info *sszInfo) Print() string {
if info == nil {
@@ -81,30 +116,28 @@ func printRecursive(info *sszInfo, builder *strings.Builder, prefix string) {
switch info.sszType {
case Container:
builder.WriteString(fmt.Sprintf("%s: %s (%s / fixed size: %d, total size: %d)\n", info.sszType, info.typ.Name(), sizeDesc, info.FixedSize(), info.Size()))
for i, key := range info.containerInfo.order {
connector := "├─"
nextPrefix := prefix + "│ "
if i == len(info.containerInfo.order)-1 {
connector = "└─"
nextPrefix = prefix + " "
}
builder.WriteString(fmt.Sprintf("%s%s %s (offset: %d) ", prefix, connector, key, info.containerInfo.fields[key].offset))
if nestedInfo := info.containerInfo.fields[key].sszInfo; nestedInfo != nil {
printRecursive(nestedInfo, builder, nextPrefix)
} else {
builder.WriteString("\n")
}
}
case List:
builder.WriteString(fmt.Sprintf("%s[%s] (%s / limit: %d, length: %d, size: %d)\n", info.sszType, info.listInfo.element.typ.Name(), sizeDesc, info.listInfo.limit, info.listInfo.length, info.Size()))
default:
builder.WriteString(fmt.Sprintf("%s (%s / size: %d)\n", info.sszType, sizeDesc, info.Size()))
}
keys := make([]string, 0, len(info.containerInfo))
for k := range info.containerInfo {
keys = append(keys, k)
}
sort.Strings(keys)
for i, key := range keys {
connector := "├─"
nextPrefix := prefix + "│ "
if i == len(keys)-1 {
connector = "└─"
nextPrefix = prefix + " "
}
builder.WriteString(fmt.Sprintf("%s%s %s (offset: %d) ", prefix, connector, key, info.containerInfo[key].offset))
if nestedInfo := info.containerInfo[key].sszInfo; nestedInfo != nil {
printRecursive(nestedInfo, builder, nextPrefix)
} else {
builder.WriteString("\n")
}
}
}

View File

@@ -14,6 +14,9 @@ func RunStructTest(t *testing.T, spec TestSpec) {
require.NoError(t, err)
testInstance := spec.Instance
err = query.PopulateVariableLengthInfo(info, testInstance)
require.NoError(t, err)
marshaller, ok := testInstance.(ssz.Marshaler)
require.Equal(t, true, ok, "Test instance must implement ssz.Marshaler, got %T", testInstance)

View File

@@ -14,8 +14,12 @@ func marshalAny(value any) ([]byte, error) {
return marshaler.MarshalSSZ()
}
// Handle custom type aliases by checking if they're based on primitive types
valueType := reflect.TypeOf(value)
if valueType.Kind() == reflect.Slice && valueType.Elem().Kind() != reflect.Uint8 {
return marshalSlice(value)
}
// Handle custom type aliases by checking if they're based on primitive types
if valueType.PkgPath() != "" {
switch valueType.Kind() {
case reflect.Uint64:
@@ -47,3 +51,25 @@ func marshalAny(value any) ([]byte, error) {
return nil, fmt.Errorf("unsupported type for SSZ marshalling: %T", value)
}
}
func marshalSlice(value any) ([]byte, error) {
valueType := reflect.TypeOf(value)
if valueType.Kind() != reflect.Slice {
return nil, fmt.Errorf("expected slice, got %T", value)
}
sliceValue := reflect.ValueOf(value)
var result []byte
// Marshal each element recursively
for i := 0; i < sliceValue.Len(); i++ {
elem := sliceValue.Index(i).Interface()
data, err := marshalAny(elem)
if err != nil {
return nil, fmt.Errorf("failed to marshal slice element at index %d: %w", i, err)
}
result = append(result, data...)
}
return result, nil
}