diff --git a/abi/dynamic/reflect_marshal.go b/abi/dynamic/reflect_marshal.go new file mode 100644 index 0000000000..90c07a1b3f --- /dev/null +++ b/abi/dynamic/reflect_marshal.go @@ -0,0 +1,170 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package dynamic + +import ( + "encoding/json" + "errors" + "fmt" + "reflect" + "regexp" + "strconv" + "strings" + + "github.com/ava-labs/avalanchego/utils/wrappers" + "golang.org/x/text/cases" + "golang.org/x/text/language" + + "github.com/ava-labs/hypersdk/abi" + "github.com/ava-labs/hypersdk/codec" + "github.com/ava-labs/hypersdk/consts" +) + +var ErrTypeNotFound = errors.New("type not found in ABI") + +func Marshal(inputABI abi.ABI, typeName string, jsonData string) ([]byte, error) { + if _, ok := findABIType(inputABI, typeName); !ok { + return nil, fmt.Errorf("marshalling %s: %w", typeName, ErrTypeNotFound) + } + + typeCache := make(map[string]reflect.Type) + + typ, err := getReflectType(typeName, inputABI, typeCache) + if err != nil { + return nil, fmt.Errorf("failed to get reflect type: %w", err) + } + + value := reflect.New(typ).Interface() + + if err := json.Unmarshal([]byte(jsonData), value); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON data: %w", err) + } + + writer := codec.NewWriter(0, consts.NetworkSizeLimit) + if err := codec.LinearCodec.MarshalInto(value, writer.Packer); err != nil { + return nil, fmt.Errorf("failed to marshal struct: %w", err) + } + + return writer.Bytes(), nil +} + +func Unmarshal(inputABI abi.ABI, typeName string, data []byte) (string, error) { + if _, ok := findABIType(inputABI, typeName); !ok { + return "", fmt.Errorf("unmarshalling %s: %w", typeName, ErrTypeNotFound) + } + + typeCache := make(map[string]reflect.Type) + + typ, err := getReflectType(typeName, inputABI, typeCache) + if err != nil { + return "", fmt.Errorf("failed to get reflect type: %w", err) + } + + value := reflect.New(typ).Interface() + + packer := wrappers.Packer{ + Bytes: data, + MaxSize: consts.NetworkSizeLimit, + } + if err := codec.LinearCodec.UnmarshalFrom(&packer, value); err != nil { + return "", fmt.Errorf("failed to unmarshal data: %w", err) + } + + jsonData, err := json.Marshal(value) + if err != nil { + return "", fmt.Errorf("failed to marshal struct to JSON: %w", err) + } + + return string(jsonData), nil +} + +// Matches fixed-size arrays like [32]uint8 +var fixedSizeArrayRegex = regexp.MustCompile(`^\[(\d+)\](.+)$`) + +func getReflectType(abiTypeName string, inputABI abi.ABI, typeCache map[string]reflect.Type) (reflect.Type, error) { + switch abiTypeName { + case "string": + return reflect.TypeOf(""), nil + case "uint8": + return reflect.TypeOf(uint8(0)), nil + case "uint16": + return reflect.TypeOf(uint16(0)), nil + case "uint32": + return reflect.TypeOf(uint32(0)), nil + case "uint64": + return reflect.TypeOf(uint64(0)), nil + case "int8": + return reflect.TypeOf(int8(0)), nil + case "int16": + return reflect.TypeOf(int16(0)), nil + case "int32": + return reflect.TypeOf(int32(0)), nil + case "int64": + return reflect.TypeOf(int64(0)), nil + case "Address": + return reflect.TypeOf(codec.Address{}), nil + default: + // golang slices + if strings.HasPrefix(abiTypeName, "[]") { + elemType, err := getReflectType(strings.TrimPrefix(abiTypeName, "[]"), inputABI, typeCache) + if err != nil { + return nil, err + } + return reflect.SliceOf(elemType), nil + } + + // golang arrays + + if match := fixedSizeArrayRegex.FindStringSubmatch(abiTypeName); match != nil { + sizeStr := match[1] + size, err := strconv.Atoi(sizeStr) + if err != nil { + return nil, fmt.Errorf("failed to convert size to int: %w", err) + } + elemType, err := getReflectType(match[2], inputABI, typeCache) + if err != nil { + return nil, err + } + return reflect.ArrayOf(size, elemType), nil + } + + // For custom types, recursively construct the struct type + if cachedType, ok := typeCache[abiTypeName]; ok { + return cachedType, nil + } + + abiType, ok := findABIType(inputABI, abiTypeName) + if !ok { + return nil, fmt.Errorf("type %s not found in ABI", abiTypeName) + } + + // It is a struct, as we don't support anything else as custom types + fields := make([]reflect.StructField, len(abiType.Fields)) + for i, field := range abiType.Fields { + fieldType, err := getReflectType(field.Type, inputABI, typeCache) + if err != nil { + return nil, err + } + fields[i] = reflect.StructField{ + Name: cases.Title(language.English).String(field.Name), + Type: fieldType, + Tag: reflect.StructTag(fmt.Sprintf(`serialize:"true" json:"%s"`, field.Name)), + } + } + + structType := reflect.StructOf(fields) + typeCache[abiTypeName] = structType + + return structType, nil + } +} + +func findABIType(inputABI abi.ABI, typeName string) (abi.Type, bool) { + for _, typ := range inputABI.Types { + if typ.Name == typeName { + return typ, true + } + } + return abi.Type{}, false +} diff --git a/abi/dynamic/reflect_marshal_test.go b/abi/dynamic/reflect_marshal_test.go new file mode 100644 index 0000000000..23bc296b9a --- /dev/null +++ b/abi/dynamic/reflect_marshal_test.go @@ -0,0 +1,94 @@ +// Copyright (C) 2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package dynamic + +import ( + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ava-labs/hypersdk/abi" +) + +func TestDynamicMarshal(t *testing.T) { + require := require.New(t) + + abiJSON := mustReadFile(t, "../testdata/abi.json") + var abi abi.ABI + + err := json.Unmarshal(abiJSON, &abi) + require.NoError(err) + + testCases := []struct { + name string + typeName string + }{ + {"empty", "MockObjectSingleNumber"}, + {"uint16", "MockObjectSingleNumber"}, + {"numbers", "MockObjectAllNumbers"}, + {"arrays", "MockObjectArrays"}, + {"transfer", "MockActionTransfer"}, + {"transferField", "MockActionWithTransfer"}, + {"transfersArray", "MockActionWithTransferArray"}, + {"strBytes", "MockObjectStringAndBytes"}, + {"strByteZero", "MockObjectStringAndBytes"}, + {"strBytesEmpty", "MockObjectStringAndBytes"}, + {"strOnly", "MockObjectStringAndBytes"}, + {"outer", "Outer"}, + {"fixedBytes", "FixedBytes"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Read the JSON data + jsonData := mustReadFile(t, "../testdata/"+tc.name+".json") + + objectBytes, err := Marshal(abi, tc.typeName, string(jsonData)) + require.NoError(err) + + // Compare with expected hex + expectedHex := string(mustReadFile(t, "../testdata/"+tc.name+".hex")) + expectedHex = strings.TrimSpace(expectedHex) + require.Equal(expectedHex, hex.EncodeToString(objectBytes)) + + unmarshaledJSON, err := Unmarshal(abi, tc.typeName, objectBytes) + require.NoError(err) + + // Compare with expected JSON + require.JSONEq(string(jsonData), unmarshaledJSON) + }) + } +} + +func TestDynamicMarshalErrors(t *testing.T) { + require := require.New(t) + + abiJSON := mustReadFile(t, "../testdata/abi.json") + var abi abi.ABI + + err := json.Unmarshal(abiJSON, &abi) + require.NoError(err) + + // Test malformed JSON + malformedJSON := `{"uint8": 42, "uint16": 1000, "uint32": 100000, "uint64": 10000000000, "int8": -42, "int16": -1000, "int32": -100000, "int64": -10000000000,` + _, err = Marshal(abi, "MockObjectAllNumbers", malformedJSON) + require.Contains(err.Error(), "unexpected end of JSON input") + + // Test wrong struct name + jsonData := mustReadFile(t, "../testdata/numbers.json") + _, err = Marshal(abi, "NonExistentObject", string(jsonData)) + require.ErrorIs(err, ErrTypeNotFound) +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + + content, err := os.ReadFile(path) + require.NoError(t, err) + return content +} diff --git a/abi/testdata/transfer.json b/abi/testdata/transfer.json index c099bc7d5e..e350cf98ab 100644 --- a/abi/testdata/transfer.json +++ b/abi/testdata/transfer.json @@ -1,5 +1,5 @@ { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/abi/testdata/transferField.json b/abi/testdata/transferField.json index 549b53fbd6..fb5833315f 100644 --- a/abi/testdata/transferField.json +++ b/abi/testdata/transferField.json @@ -1,6 +1,6 @@ { "transfer": { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/abi/testdata/transfersArray.json b/abi/testdata/transfersArray.json index d4b3989b33..7ce4ab7ccf 100644 --- a/abi/testdata/transfersArray.json +++ b/abi/testdata/transfersArray.json @@ -1,12 +1,12 @@ { "transfers": [ { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" }, { - "to": "0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", + "to": "0x0102030405060708090a0b0c0d0e0f101112131400000000000000000000000000", "value": 1000, "memo": "aGk=" } diff --git a/go.mod b/go.mod index d5973f6ca4..0072fe61c1 100644 --- a/go.mod +++ b/go.mod @@ -27,6 +27,7 @@ require ( golang.org/x/crypto v0.22.0 golang.org/x/exp v0.0.0-20231127185646-65229373498e golang.org/x/sync v0.7.0 + golang.org/x/text v0.14.0 google.golang.org/grpc v1.62.0 google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v2 v2.4.0 @@ -143,7 +144,6 @@ require ( golang.org/x/net v0.24.0 // indirect golang.org/x/sys v0.19.0 // indirect golang.org/x/term v0.19.0 // indirect - golang.org/x/text v0.14.0 // indirect golang.org/x/time v0.3.0 // indirect golang.org/x/tools v0.17.0 // indirect gonum.org/v1/gonum v0.11.0 // indirect