From aa64827126a97318823de90416a3577437fe7e9c Mon Sep 17 00:00:00 2001 From: pinosu <95283998+pinosu@users.noreply.github.com> Date: Thu, 18 Apr 2024 16:51:31 +0200 Subject: [PATCH] feat(x/tx): Sort JSON attributes for "inline_json" encoder (#20049) --- x/tx/signing/aminojson/encoder.go | 47 +++++++++++++- x/tx/signing/aminojson/encoder_test.go | 84 +++++++++++++++++++++++++- 2 files changed, 126 insertions(+), 5 deletions(-) diff --git a/x/tx/signing/aminojson/encoder.go b/x/tx/signing/aminojson/encoder.go index d799eb52e627..9fe589c0e544 100644 --- a/x/tx/signing/aminojson/encoder.go +++ b/x/tx/signing/aminojson/encoder.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "github.com/pkg/errors" "google.golang.org/protobuf/reflect/protoreflect" @@ -92,10 +93,11 @@ func nullSliceAsEmptyEncoder(enc *Encoder, v protoreflect.Value, w io.Writer) er func cosmosInlineJSON(_ *Encoder, v protoreflect.Value, w io.Writer) error { switch bz := v.Interface().(type) { case []byte: - if !json.Valid(bz) { - return errors.New("invalid JSON bytes") + json, err := sortedJsonStringify(bz) + if err != nil { + return errors.Wrap(err, "could not normalize JSON") } - _, err := w.Write(bz) + _, err = w.Write(json) return err default: return fmt.Errorf("unsupported type %T", bz) @@ -189,3 +191,42 @@ func thresholdStringEncoder(enc *Encoder, msg protoreflect.Message, w io.Writer) _, err = io.WriteString(w, `}`) return err } + +// sortedObject returns a new object that mirrors the structure of the original +// but with all maps having their keys sorted. +func sortedObject(obj interface{}) interface{} { + switch v := obj.(type) { + case map[string]interface{}: + sortedKeys := make([]string, 0, len(v)) + for key := range v { + sortedKeys = append(sortedKeys, key) + } + sort.Strings(sortedKeys) + result := make(map[string]interface{}) + for _, key := range sortedKeys { + result[key] = sortedObject(v[key]) + } + return result + case []interface{}: + for i, val := range v { + v[i] = sortedObject(val) + } + return v + default: + return obj + } +} + +// sortedJsonStringify returns a JSON with objects sorted by key. +func sortedJsonStringify(jsonBytes []byte) ([]byte, error) { + var obj interface{} + if err := json.Unmarshal(jsonBytes, &obj); err != nil { + return nil, errors.New("invalid JSON bytes") + } + sorted := sortedObject(obj) + jsonData, err := json.Marshal(sorted) + if err != nil { + return nil, err + } + return jsonData, nil +} diff --git a/x/tx/signing/aminojson/encoder_test.go b/x/tx/signing/aminojson/encoder_test.go index 1ebf9990ac38..29e08a39177f 100644 --- a/x/tx/signing/aminojson/encoder_test.go +++ b/x/tx/signing/aminojson/encoder_test.go @@ -26,10 +26,10 @@ func TestCosmosInlineJSON(t *testing.T) { wantErr: false, wantOutput: `[1,2,3]`, }, - "supported type - valid JSON is not normalized": { + "supported type - valid JSON is normalized": { value: protoreflect.ValueOfBytes([]byte(`[1, 2, 3]`)), wantErr: false, - wantOutput: `[1, 2, 3]`, + wantOutput: `[1,2,3]`, }, "supported type - valid JSON array (empty)": { value: protoreflect.ValueOfBytes([]byte(`[]`)), @@ -92,3 +92,83 @@ func TestCosmosInlineJSON(t *testing.T) { }) } } + +func TestSortedJsonStringify(t *testing.T) { + tests := map[string]struct { + input []byte + wantOutput string + }{ + "leaves true unchanged": { + input: []byte(`true`), + wantOutput: "true", + }, + "leaves false unchanged": { + input: []byte(`false`), + wantOutput: "false", + }, + "leaves string unchanged": { + input: []byte(`"aabbccdd"`), + wantOutput: `"aabbccdd"`, + }, + "leaves number unchanged": { + input: []byte(`75`), + wantOutput: "75", + }, + "leaves nil unchanged": { + input: []byte(`null`), + wantOutput: "null", + }, + "leaves simple array unchanged": { + input: []byte(`[5, 6, 7, 1]`), + wantOutput: "[5,6,7,1]", + }, + "leaves complex array unchanged": { + input: []byte(`[5, ["a", "b"], true, null, 1]`), + wantOutput: `[5,["a","b"],true,null,1]`, + }, + "sorts empty object": { + input: []byte(`{}`), + wantOutput: `{}`, + }, + "sorts single key object": { + input: []byte(`{"a": 3}`), + wantOutput: `{"a":3}`, + }, + "sorts multiple keys object": { + input: []byte(`{"a": 3, "b": 2, "c": 1}`), + wantOutput: `{"a":3,"b":2,"c":1}`, + }, + "sorts unsorted object": { + input: []byte(`{"b": 2, "a": 3, "c": 1}`), + wantOutput: `{"a":3,"b":2,"c":1}`, + }, + "sorts unsorted complex object": { + input: []byte(`{"aaa": true, "aa": true, "a": true}`), + wantOutput: `{"a":true,"aa":true,"aaa":true}`, + }, + "sorts nested objects": { + input: []byte(`{"x": {"y": {"z": null}}}`), + wantOutput: `{"x":{"y":{"z":null}}}`, + }, + "sorts deeply nested unsorted objects": { + input: []byte(`{"b": {"z": true, "x": true, "y": true}, "a": true, "c": true}`), + wantOutput: `{"a":true,"b":{"x":true,"y":true,"z":true},"c":true}`, + }, + "sorts objects in array sorted": { + input: []byte(`[1, 2, {"x": {"y": {"z": null}}}, 4]`), + wantOutput: `[1,2,{"x":{"y":{"z":null}}},4]`, + }, + "sorts objects in array unsorted": { + input: []byte(`[1, 2, {"b": {"z": true, "x": true, "y": true}, "a": true, "c": true}, 4]`), + wantOutput: `[1,2,{"a":true,"b":{"x":true,"y":true,"z":true},"c":true},4]`, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got, err := sortedJsonStringify(tc.input) + require.NoError(t, err) + assert.Equal(t, tc.wantOutput, string(got)) + }) + } +}