Skip to content

Commit

Permalink
feat(x/tx): Sort JSON attributes for "inline_json" encoder (cosmos#20049
Browse files Browse the repository at this point in the history
)
  • Loading branch information
pinosu authored Apr 18, 2024
1 parent bbd16af commit aa64827
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 5 deletions.
47 changes: 44 additions & 3 deletions x/tx/signing/aminojson/encoder.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"sort"

"github.com/pkg/errors"
"google.golang.org/protobuf/reflect/protoreflect"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
84 changes: 82 additions & 2 deletions x/tx/signing/aminojson/encoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(`[]`)),
Expand Down Expand Up @@ -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))
})
}
}

0 comments on commit aa64827

Please sign in to comment.