diff --git a/client/v2/CHANGELOG.md b/client/v2/CHANGELOG.md index 5fae2c385bdc..831ee40ce922 100644 --- a/client/v2/CHANGELOG.md +++ b/client/v2/CHANGELOG.md @@ -43,6 +43,10 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#18626](https://github.com/cosmos/cosmos-sdk/pull/18626) Support for off-chain signing and verification of a file. * [#18461](https://github.com/cosmos/cosmos-sdk/pull/18461) Support governance proposals. +### Improvements + +* [#21712](https://github.com/cosmos/cosmos-sdk/pull/21712) Marshal `type` field as proto message url in queries instead of amino name. + ### API Breaking Changes * [#17709](https://github.com/cosmos/cosmos-sdk/pull/17709) Address codecs have been removed from `autocli.AppOptions` and `flag.Builder`. Instead client/v2 uses the address codecs present in the context (introduced in [#17503](https://github.com/cosmos/cosmos-sdk/pull/17503)). diff --git a/client/v2/autocli/query.go b/client/v2/autocli/query.go index dc268b573654..166be5efebdc 100644 --- a/client/v2/autocli/query.go +++ b/client/v2/autocli/query.go @@ -119,11 +119,12 @@ func (b *Builder) BuildQueryMethodCommand(ctx context.Context, descriptor protor methodName := fmt.Sprintf("/%s/%s", serviceDescriptor.FullName(), descriptor.Name()) outputType := util.ResolveMessageType(b.TypeResolver, descriptor.Output()) encoderOptions := aminojson.EncoderOptions{ - Indent: " ", - EnumAsString: true, - DoNotSortFields: true, - TypeResolver: b.TypeResolver, - FileResolver: b.FileResolver, + Indent: " ", + EnumAsString: true, + DoNotSortFields: true, + AminoNameAsTypeURL: true, + TypeResolver: b.TypeResolver, + FileResolver: b.FileResolver, } cmd, err := b.buildMethodCommandCommon(descriptor, options, func(cmd *cobra.Command, input protoreflect.Message) error { diff --git a/x/tx/CHANGELOG.md b/x/tx/CHANGELOG.md new file mode 100644 index 000000000000..d75a44787385 --- /dev/null +++ b/x/tx/CHANGELOG.md @@ -0,0 +1,191 @@ + + +# Changelog + +## [Unreleased] + +### Improvements + +* [#21712](https://github.com/cosmos/cosmos-sdk/pull/21712) Add `AminoNameAsTypeURL` option to Amino JSON encoder. +* [#21073](https://github.com/cosmos/cosmos-sdk/pull/21073) In Context use sync.Map `getSignersFuncs` map from concurrent writes, we also need to call Validate when using the legacy app. + +## [v0.13.3](https://github.com/cosmos/cosmos-sdk/releases/tag/x/tx/v0.13.3) - 2024-04-22 + +### Improvements + +* [#20049](https://github.com/cosmos/cosmos-sdk/pull/20049) Sort JSON attributes for `inline_json` encoder. + +## [v0.13.2](https://github.com/cosmos/cosmos-sdk/releases/tag/x/tx/v0.13.2) - 2024-04-12 + +### Features + +* [#19786](https://github.com/cosmos/cosmos-sdk/pull/19786)/[#19919](https://github.com/cosmos/cosmos-sdk/pull/19919) Add "inline_json" option to Amino JSON encoder. + +### Improvements + +* [#19845](https://github.com/cosmos/cosmos-sdk/pull/19845) Use hybrid resolver instead of only protov2 registry + +### Bug Fixes + +* [#19955](https://github.com/cosmos/cosmos-sdk/pull/19955) Don't shadow Amino marshalling error messages + +## [v0.13.1](https://github.com/cosmos/cosmos-sdk/releases/tag/x/tx/v0.13.1) - 2024-03-05 + +### Features + +* [#19618](https://github.com/cosmos/cosmos-sdk/pull/19618) Add enum as string option to encoder. + +### Improvements + +* [#18857](https://github.com/cosmos/cosmos-sdk/pull/18857) Moved `FormatCoins` from `core/coins` to this package under `signing/textual`. + +### Bug Fixes + +* [#19265](https://github.com/cosmos/cosmos-sdk/pull/19265) Reject denoms that contain a comma. + +## [v0.13.0](https://github.com/cosmos/cosmos-sdk/releases/tag/x/tx/v0.13.0) - 2023-12-19 + +### Improvements + +* [#18740](https://github.com/cosmos/cosmos-sdk/pull/18740) Support nested messages when fetching signers up to a default depth of 32. + +## v0.12.0 + +### Improvements + +* [#18309](https://github.com/cosmos/cosmos-sdk/pull/18309) Update encoder so that amino types default to msg type url. + +## v0.11.0 + +### Improvements + +* [#17787](https://github.com/cosmos/cosmos-sdk/pull/17787) Drop tip support. + +## v0.10.0 + +### Features + +* [#17681](https://github.com/cosmos/cosmos-sdk/pull/17681) Add encoder `DefineTypeEncoding` method for defining custom type encodings. +* [#17600](https://github.com/cosmos/cosmos-sdk/pull/17600) Add encoder `DefineScalarEncoding` method for defining custom scalar encodings. +* [#17600](https://github.com/cosmos/cosmos-sdk/pull/17600) Add indent option to encoder. + +## v0.9.1 + +### Improvements + +* [#16936](https://github.com/cosmos/cosmos-sdk/pull/16936) Remove extra whitespace when marshalling module accounts. + +## v0.9.0 + +### Bug Fixes + +* [#16681](https://github.com/cosmos/cosmos-sdk/pull/16681): Catch and fix `(*Decoder).Decode` crash from invalid length prefix in Tx bytes. + +### Improvements + +* [#16846](https://github.com/cosmos/cosmos-sdk/pull/16846): Harmonize interface `signing.TypeResolver` with the rest of the codebase (orm and client/v2). +* [#16684](https://github.com/cosmos/cosmos-sdk/pull/16684): Use `io.WriteString`+`fmt.Fprintf` to remove unnecessary `string`->`[]byte` roundtrip. + +## v0.8.0 + +### Improvements + +* [#16340](https://github.com/cosmos/cosmos-sdk/pull/16340): add `DefineCustomGetSigners` API function. + +## v0.7.0 + +### API Breaking + +* [#16044](https://github.com/cosmos/cosmos-sdk/pull/16044): rename aminojson.NewAminoJSON -> aminojson.NewEncoder. +* [#16047](https://github.com/cosmos/cosmos-sdk/pull/16047): aminojson.NewEncoder now takes EncoderOptions as an argument. +* [#16254](https://github.com/cosmos/cosmos-sdk/pull/16254): aminojson.Encoder.Marshal now sorts all fields like encoding/json.Marshal does, hence no more need for sdk.\*SortJSON. + +## v0.6.2 + +### Improvements + +* [#15873](https://github.com/cosmos/cosmos-sdk/pull/15873): add `Validate` method and only check for errors when `Validate` is explicitly called. + +## v0.6.1 + +### Improvements + +* [#15871](https://github.com/cosmos/cosmos-sdk/pull/15871) + * `HandlerMap` now has a `DefaultMode()` getter method + * Textual types use `signing.ProtoFileResolver` instead of `protoregistry.Files` + +## v0.6.0 + +### API Breaking + +* [#15709](https://github.com/cosmos/cosmos-sdk/pull/15709): + * `GetSignersContext` has been renamed to `signing.Context` + * `GetSigners` now returns `[][]byte` instead of `[]string` + * `GetSignersOptions` has been renamed to `signing.Options` and requires `address.Codec`s for account and validator addresses + * `GetSignersOptions.ProtoFiles` has been renamed to `signing.Options.FileResolver` + +### Bug Fixes + +* [#15849](https://github.com/cosmos/cosmos-sdk/pull/15849) Fix int64 usage for 32 bit platforms. + +## v0.5.1 + +### Features + +* [#15414](https://github.com/cosmos/cosmos-sdk/pull/15414) Add basic transaction decoding support. + +## v0.5.0 + +### API Breaking + +* [#15581](https://github.com/cosmos/cosmos-sdk/pull/15581) `GetSignersOptions` and `directaux.SignModeHandlerOptions` now +require a `signing.ProtoFileResolver` interface instead of `protodesc.Resolver`. +* [#15742](https://github.com/cosmos/cosmos-sdk/pull/15742) The `direct_aux` package has been renamed to `directaux` in line with Go conventions. No other types were changed during the package rename. +* [#15748](https://github.com/cosmos/cosmos-sdk/pull/15748) Rename signing.SignerData.ChainId to .ChainID, in line with Go conventions. + +### Bug Fixes + +* (signing/textual) [#15730](https://github.com/cosmos/cosmos-sdk/pull/15730) make IntValueRenderer.Parse: gracefully handle "" + fuzz + +## v0.4.0 + +### API Breaking + +* [#13793](https://github.com/cosmos/cosmos-sdk/pull/13793) `direct_aux.NewSignModeHandler` constructor function now returns an additional error argument. +* [#15278](https://github.com/cosmos/cosmos-sdk/pull/15278) Move `x/tx/{textual,aminojson}` into `x/tx/signing`. +* [#15302](https://github.com/cosmos/cosmos-sdk/pull/15302) `textual.NewSignModeHandler` now takes an options struct instead of a simple coin querier argument. It also returns an error. + +### Improvements + +* [#15302](https://github.com/cosmos/cosmos-sdk/pull/15302) Add support for a custom registry (e.g. gogo's MergedRegistry) to be plugged into SIGN_MODE_TEXTUAL. +* [#15557](https://github.com/cosmos/cosmos-sdk/pull/15557) Implement unknown field filtering. +* [#15515](https://github.com/cosmos/cosmos-sdk/pull/15515) Implement SIGN_MODE_LEGACY_AMINO_JSON handler. diff --git a/x/tx/signing/aminojson/json_marshal.go b/x/tx/signing/aminojson/json_marshal.go new file mode 100644 index 000000000000..52defcca6357 --- /dev/null +++ b/x/tx/signing/aminojson/json_marshal.go @@ -0,0 +1,435 @@ +package aminojson + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/pkg/errors" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + + "cosmossdk.io/x/tx/signing" +) + +// MessageEncoder is a function that can encode a protobuf protoreflect.Message to JSON. +type MessageEncoder func(*Encoder, protoreflect.Message, io.Writer) error + +// FieldEncoder is a function that can encode a protobuf protoreflect.Value to JSON. +type FieldEncoder func(*Encoder, protoreflect.Value, io.Writer) error + +// EncoderOptions are options for creating a new Encoder. +type EncoderOptions struct { + // Indent can only be composed of space or tab characters. + // It defines the indentation used for each level of indentation. + Indent string + // DoNotSortFields when set turns off sorting of field names. + DoNotSortFields bool + // EnumAsString when set will encode enums as strings instead of integers. + // Caution: Enabling this option produce different sign bytes. + EnumAsString bool + // AminoNameAsTypeURL when set will use the amino name as the type URL in the JSON output. + // It is useful when using the Amino JSON encoder for non Amino purposes, + // such as JSON RPC. + AminoNameAsTypeURL bool + // TypeResolver is used to resolve protobuf message types by TypeURL when marshaling any packed messages. + TypeResolver signing.TypeResolver + // FileResolver is used to resolve protobuf file descriptors TypeURL when TypeResolver fails. + FileResolver signing.ProtoFileResolver +} + +// Encoder is a JSON encoder that uses the Amino JSON encoding rules for protobuf messages. +type Encoder struct { + // maps cosmos_proto.scalar -> field encoder + cosmosProtoScalarEncoders map[string]FieldEncoder + aminoMessageEncoders map[string]MessageEncoder + aminoFieldEncoders map[string]FieldEncoder + protoTypeEncoders map[string]MessageEncoder + fileResolver signing.ProtoFileResolver + typeResolver protoregistry.MessageTypeResolver + doNotSortFields bool + indent string + enumsAsString bool + aminoNameAsTypeURL bool +} + +// NewEncoder returns a new Encoder capable of serializing protobuf messages to JSON using the Amino JSON encoding +// rules. +func NewEncoder(options EncoderOptions) Encoder { + if options.FileResolver == nil { + options.FileResolver = gogoproto.HybridResolver + } + if options.TypeResolver == nil { + options.TypeResolver = protoregistry.GlobalTypes + } + enc := Encoder{ + cosmosProtoScalarEncoders: map[string]FieldEncoder{ + "cosmos.Dec": cosmosDecEncoder, + "cosmos.Int": cosmosIntEncoder, + }, + aminoMessageEncoders: map[string]MessageEncoder{ + "key_field": keyFieldEncoder, + "module_account": moduleAccountEncoder, + "threshold_string": thresholdStringEncoder, + }, + aminoFieldEncoders: map[string]FieldEncoder{ + "legacy_coins": nullSliceAsEmptyEncoder, + "inline_json": cosmosInlineJSON, + }, + protoTypeEncoders: map[string]MessageEncoder{ + "google.protobuf.Timestamp": marshalTimestamp, + "google.protobuf.Duration": marshalDuration, + "google.protobuf.Any": marshalAny, + }, + fileResolver: options.FileResolver, + typeResolver: options.TypeResolver, + doNotSortFields: options.DoNotSortFields, + indent: options.Indent, + enumsAsString: options.EnumAsString, + aminoNameAsTypeURL: options.AminoNameAsTypeURL, + } + return enc +} + +// DefineMessageEncoding defines a custom encoding for a protobuf message. The `name` field must match a usage of +// an (amino.message_encoding) option in the protobuf message as in the following example. This encoding will be +// used instead of the default encoding for all usages of the tagged message. +// +// message ModuleAccount { +// option (amino.name) = "cosmos-sdk/ModuleAccount"; +// option (amino.message_encoding) = "module_account"; +// ... +// } +func (enc Encoder) DefineMessageEncoding(name string, encoder MessageEncoder) Encoder { + if enc.aminoMessageEncoders == nil { + enc.aminoMessageEncoders = map[string]MessageEncoder{} + } + enc.aminoMessageEncoders[name] = encoder + return enc +} + +// DefineFieldEncoding defines a custom encoding for a protobuf field. The `name` field must match a usage of +// an (amino.encoding) option in the protobuf message as in the following example. This encoding will be used +// instead of the default encoding for all usages of the tagged field. +// +// message Balance { +// repeated cosmos.base.v1beta1.Coin coins = 2 [ +// (amino.encoding) = "legacy_coins", +// (gogoproto.castrepeated) = "github.com/cosmos/cosmos-sdk/types.Coins", +// (gogoproto.nullable) = false, +// (amino.dont_omitempty) = true +// ]; +// ... +// } +func (enc Encoder) DefineFieldEncoding(name string, encoder FieldEncoder) Encoder { + if enc.aminoFieldEncoders == nil { + enc.aminoFieldEncoders = map[string]FieldEncoder{} + } + enc.aminoFieldEncoders[name] = encoder + return enc +} + +// DefineScalarEncoding defines a custom encoding for a protobuf scalar field. The `name` field must match a usage of +// an (cosmos_proto.scalar) option in the protobuf message as in the following example. This encoding will be used +// instead of the default encoding for all usages of the tagged field. +// +// message Balance { +// string address = 1 [(cosmos_proto.scalar) = "cosmos.AddressString"]; +// ... +// } +func (enc Encoder) DefineScalarEncoding(name string, encoder FieldEncoder) Encoder { + if enc.cosmosProtoScalarEncoders == nil { + enc.cosmosProtoScalarEncoders = map[string]FieldEncoder{} + } + enc.cosmosProtoScalarEncoders[name] = encoder + return enc +} + +// DefineTypeEncoding defines a custom encoding for a protobuf message type. The `typeURL` field must match the +// type of the protobuf message as in the following example. This encoding will be used instead of the default +// encoding for all usages of the tagged message. +// +// message Foo { +// google.protobuf.Duration type_url = 1; +// ... +// } + +func (enc Encoder) DefineTypeEncoding(typeURL string, encoder MessageEncoder) Encoder { + if enc.protoTypeEncoders == nil { + enc.protoTypeEncoders = map[string]MessageEncoder{} + } + enc.protoTypeEncoders[typeURL] = encoder + return enc +} + +// Marshal serializes a protobuf message to JSON. +func (enc Encoder) Marshal(message proto.Message) ([]byte, error) { + buf := &bytes.Buffer{} + err := enc.beginMarshal(message.ProtoReflect(), buf, false) + if err != nil { + return nil, err + } + + if enc.indent != "" { + indentBuf := &bytes.Buffer{} + if err := json.Indent(indentBuf, buf.Bytes(), "", enc.indent); err != nil { + return nil, err + } + + return indentBuf.Bytes(), nil + } + + return buf.Bytes(), nil +} + +func (enc Encoder) beginMarshal(msg protoreflect.Message, writer io.Writer, isAny bool) error { + var ( + name string + named bool + ) + + if isAny { + if enc.aminoNameAsTypeURL { + name, named = getMessageTypeURL(msg), true + } else { + name, named = getMessageAminoNameAny(msg), true + } + } else { + name, named = getMessageAminoName(msg) + if enc.aminoNameAsTypeURL { + // do not override named + name = getMessageTypeURL(msg) + } + } + + if named { + _, err := fmt.Fprintf(writer, `{"type":"%s","value":`, name) + if err != nil { + return err + } + } + + err := enc.marshal(protoreflect.ValueOfMessage(msg), nil /* no field descriptor needed here */, writer) + if err != nil { + return err + } + + if named { + _, err = io.WriteString(writer, "}") + if err != nil { + return err + } + } + + return nil +} + +func (enc Encoder) marshal(value protoreflect.Value, fd protoreflect.FieldDescriptor, writer io.Writer) error { + switch val := value.Interface().(type) { + case protoreflect.Message: + err := enc.marshalMessage(val, writer) + return err + + case protoreflect.Map: + return errors.New("maps are not supported") + + case protoreflect.List: + if !val.IsValid() { + _, err := io.WriteString(writer, "null") + return err + } + return enc.marshalList(val, fd, writer) + + case string, bool, int32, uint32, []byte: + return jsonMarshal(writer, val) + + case protoreflect.EnumNumber: + if enc.enumsAsString && fd != nil { + desc := fd.Enum().Values().ByNumber(val) + if desc != nil { + _, err := io.WriteString(writer, fmt.Sprintf(`"%s"`, desc.Name())) + return err + } + } + + return jsonMarshal(writer, val) + + case uint64, int64: + _, err := fmt.Fprintf(writer, `"%d"`, val) // quoted + return err + + default: + return errors.Errorf("unknown type %T", val) + } +} + +type nameAndIndex struct { + i int + name string +} + +func (enc Encoder) marshalMessage(msg protoreflect.Message, writer io.Writer) error { + if msg == nil { + return errors.New("nil message") + } + + // check if we have a custom type encoder for this type + if typeEnc, ok := enc.protoTypeEncoders[string(msg.Descriptor().FullName())]; ok { + return typeEnc(&enc, msg, writer) + } + + if encoder := enc.getMessageEncoder(msg); encoder != nil { + err := encoder(&enc, msg, writer) + return err + } + + _, err := io.WriteString(writer, "{") + if err != nil { + return err + } + + fields := msg.Descriptor().Fields() + first := true + emptyOneOfWritten := map[string]bool{} + + // 1. If permitted, ensure the names are sorted. + indices := make([]*nameAndIndex, 0, fields.Len()) + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + name := getAminoFieldName(f) + indices = append(indices, &nameAndIndex{i: i, name: name}) + } + + if shouldSortFields := !enc.doNotSortFields; shouldSortFields { + sort.Slice(indices, func(i, j int) bool { + ni, nj := indices[i], indices[j] + return ni.name < nj.name + }) + } + + for _, ni := range indices { + i := ni.i + name := ni.name + f := fields.Get(i) + v := msg.Get(f) + oneof := f.ContainingOneof() + isOneOf := oneof != nil + oneofFieldName, oneofTypeName, err := getOneOfNames(f) + if err != nil && isOneOf { + return err + } + writeNil := false + + if !msg.Has(f) { + // msg.WhichOneof(oneof) == nil: no field of the oneof has been set + // !emptyOneOfWritten: we haven't written a null for this oneof yet (only write one null per empty oneof) + switch { + case isOneOf && msg.WhichOneof(oneof) == nil && !emptyOneOfWritten[oneofFieldName]: + name = oneofFieldName + writeNil = true + emptyOneOfWritten[oneofFieldName] = true + case omitEmpty(f): + continue + case f.Kind() == protoreflect.MessageKind && + f.Cardinality() != protoreflect.Repeated && + !v.Message().IsValid(): + return errors.Errorf("not supported: dont_omit_empty=true on invalid (nil?) message field: %s", name) + } + } + + if !first { + _, err = io.WriteString(writer, ",") + if err != nil { + return err + } + } + + if isOneOf && !writeNil { + _, err = fmt.Fprintf(writer, `"%s":{"type":"%s","value":{`, oneofFieldName, oneofTypeName) + if err != nil { + return err + } + } + + err = jsonMarshal(writer, name) + if err != nil { + return err + } + + _, err = io.WriteString(writer, ":") + if err != nil { + return err + } + + // encode value + if encoder := enc.getFieldEncoding(f); encoder != nil { + err = encoder(&enc, v, writer) + if err != nil { + return err + } + } else if writeNil { + _, err = io.WriteString(writer, "null") + if err != nil { + return err + } + } else { + err = enc.marshal(v, f, writer) + if err != nil { + return err + } + } + + if isOneOf && !writeNil { + _, err = io.WriteString(writer, "}}") + if err != nil { + return err + } + } + + first = false + } + + _, err = io.WriteString(writer, "}") + return err +} + +func jsonMarshal(w io.Writer, v interface{}) error { + blob, err := json.Marshal(v) + if err != nil { + return err + } + _, err = w.Write(blob) + return err +} + +func (enc Encoder) marshalList(list protoreflect.List, fd protoreflect.FieldDescriptor, writer io.Writer) error { + n := list.Len() + + _, err := io.WriteString(writer, "[") + if err != nil { + return err + } + + first := true + for i := 0; i < n; i++ { + if !first { + _, err := io.WriteString(writer, ",") + if err != nil { + return err + } + } + first = false + + err = enc.marshal(list.Get(i), fd, writer) + if err != nil { + return err + } + } + + _, err = io.WriteString(writer, "]") + return err +} diff --git a/x/tx/signing/aminojson/json_marshal_test.go b/x/tx/signing/aminojson/json_marshal_test.go new file mode 100644 index 000000000000..29d2f8cf8f59 --- /dev/null +++ b/x/tx/signing/aminojson/json_marshal_test.go @@ -0,0 +1,416 @@ +package aminojson_test + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "testing" + "time" + + "github.com/cosmos/cosmos-proto/rapidproto" + "github.com/stretchr/testify/require" + "github.com/tendermint/go-amino" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/dynamicpb" + "google.golang.org/protobuf/types/known/durationpb" + "gotest.tools/v3/assert" + "pgregory.net/rapid" + + "cosmossdk.io/x/tx/signing/aminojson" + "cosmossdk.io/x/tx/signing/aminojson/internal/aminojsonpb" + "cosmossdk.io/x/tx/signing/aminojson/internal/testpb" +) + +func marshalLegacy(msg proto.Message) ([]byte, error) { + cdc := amino.NewCodec() + cdc.RegisterConcrete(&testpb.ABitOfEverything{}, "ABitOfEverything", nil) + cdc.RegisterConcrete(&testpb.NestedMessage{}, "NestedMessage", nil) + return cdc.MarshalJSON(msg) +} + +func TestAminoJSON_EdgeCases(t *testing.T) { + cdc := amino.NewCodec() + cdc.RegisterConcrete(&testpb.ABitOfEverything{}, "ABitOfEverything", nil) + cdc.RegisterConcrete(&testpb.NestedMessage{}, "NestedMessage", nil) + aj := aminojson.NewEncoder(aminojson.EncoderOptions{}) + + cases := map[string]struct { + msg proto.Message + shouldErr bool + }{ + "empty": {msg: &testpb.ABitOfEverything{}}, + "single map": {msg: &testpb.WithAMap{StrMap: map[string]string{"foo": "bar"}}, shouldErr: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + bz, err := aj.Marshal(tc.msg) + + if tc.shouldErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + + rv := reflect.New(reflect.TypeOf(tc.msg).Elem()).Elem() + msg2 := rv.Addr().Interface().(proto.Message) + + legacyBz, err := cdc.MarshalJSON(tc.msg) + assert.NilError(t, err) + + require.Equal(t, string(legacyBz), string(bz)) + + goProtoJSON, err := protojson.Marshal(tc.msg) + assert.NilError(t, err) + err = cdc.UnmarshalJSON(bz, msg2) + assert.NilError(t, err, "unmarshal failed: %s vs %s", legacyBz, goProtoJSON) + }) + } +} + +func TestAminoJSON(t *testing.T) { + cdc := amino.NewCodec() + cdc.RegisterConcrete(&testpb.ABitOfEverything{}, "ABitOfEverything", nil) + cdc.RegisterConcrete(&testpb.NestedMessage{}, "NestedMessage", nil) + + msg := &testpb.ABitOfEverything{ + Message: &testpb.NestedMessage{ + Foo: "test", + Bar: 0, // this is the default value and should be omitted from output + }, + Enum: testpb.AnEnum_ONE, + Repeated: []int32{3, -7, 2, 6, 4}, + Str: `abcxyz"foo"def`, + Bool: true, + Bytes: []byte{0, 1, 2, 3}, + I32: -15, + F32: 1001, + U32: 1200, + Si32: -376, + Sf32: -1000, + I64: 14578294827584932, + F64: 9572348124213523654, + U64: 4759492485, + Si64: -59268425823934, + Sf64: -659101379604211154, + } + + unsortedBz, err := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true}).Marshal(msg) + assert.NilError(t, err) + legacyBz, err := cdc.MarshalJSON(msg) + assert.NilError(t, err) + require.Equal(t, string(legacyBz), string(unsortedBz)) + + // Now ensure that the default encoder behavior sorts fields and that they match + // as we'd have them from encoding/json.Marshal. + // Please see https://github.com/cosmos/cosmos-sdk/issues/2350 + encodedDefaultBz, err := aminojson.NewEncoder(aminojson.EncoderOptions{}).Marshal(msg) + assert.NilError(t, err) + + // Ensure that it is NOT equal to the legacy JSON but that it is equal to the sorted JSON. + require.NotEqual(t, string(legacyBz), string(encodedDefaultBz)) + + // Now ensure that the legacy's sortedJSON is as the aminojson.Encoder would produce. + // This proves that we can eliminate the use of sdk.*SortJSON(encoderBz) + sortedBz := naiveSortedJSON(t, unsortedBz) + require.Equal(t, string(sortedBz), string(encodedDefaultBz)) +} + +func naiveSortedJSON(tb testing.TB, jsonToSort []byte) []byte { + tb.Helper() + var c interface{} + err := json.Unmarshal(jsonToSort, &c) + assert.NilError(tb, err) + sortedBz, err := json.Marshal(c) + assert.NilError(tb, err) + return sortedBz +} + +func TestRapid(t *testing.T) { + gen := rapidproto.MessageGenerator(&testpb.ABitOfEverything{}, rapidproto.GeneratorOptions{}) + rapid.Check(t, func(t *rapid.T) { + msg := gen.Draw(t, "msg") + bz, err := aminojson.NewEncoder(aminojson.EncoderOptions{DoNotSortFields: true}).Marshal(msg) + assert.NilError(t, err) + checkInvariants(t, msg, bz) + }) +} + +func checkInvariants(t *rapid.T, message proto.Message, marshaledBytes []byte) { + checkLegacyParity(t, message, marshaledBytes) + checkRoundTrip(t, message, marshaledBytes) +} + +func checkLegacyParity(t *rapid.T, message proto.Message, marshaledBytes []byte) { + legacyBz, err := marshalLegacy(message) + assert.NilError(t, err) + require.Equal(t, string(legacyBz), string(marshaledBytes), "%s vs legacy: %s", string(marshaledBytes), + string(legacyBz)) +} + +func checkRoundTrip(t *rapid.T, message proto.Message, marshaledBytes []byte) { + cdc := amino.NewCodec() + cdc.RegisterConcrete(&testpb.ABitOfEverything{}, "ABitOfEverything", nil) + cdc.RegisterConcrete(&testpb.NestedMessage{}, "NestedMessage", nil) + + message2 := message.ProtoReflect().New().Interface() + goProtoJSON, err := cdc.MarshalJSON(message) + assert.NilError(t, err) + err = cdc.UnmarshalJSON(marshaledBytes, message2) + assert.NilError(t, err, "%s vs %s", string(marshaledBytes), string(goProtoJSON)) +} + +func TestDynamicPb(t *testing.T) { + msg := &aminojsonpb.AminoSignFee{} + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{}) + + desc, err := protoregistry.GlobalFiles.FindDescriptorByName(proto.MessageName(msg)) + require.NoError(t, err) + dynamicMsgType := dynamicpb.NewMessageType(desc.(protoreflect.MessageDescriptor)) + dynamicMsg := dynamicMsgType.New().Interface() + + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + dynamicBz, err := encoder.Marshal(dynamicMsg) + require.NoError(t, err) + require.Equal(t, string(bz), string(dynamicBz)) +} + +func TestMarshalDuration(t *testing.T) { + msg := &testpb.Duration{ + Duration: &durationpb.Duration{Seconds: 1}, + } + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{}) + + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + require.Equal(t, `{"duration":"1000000000"}`, string(bz)) + + // define a custom marshaler for duration + encoder.DefineTypeEncoding("google.protobuf.Duration", func(_ *aminojson.Encoder, msg protoreflect.Message, w io.Writer) error { + var secondsName protoreflect.Name = "seconds" + + fields := msg.Descriptor().Fields() + secondsField := fields.ByName(secondsName) + if secondsField == nil { + return errors.New("expected seconds field") + } + seconds := msg.Get(secondsField).Int() + + _, err = fmt.Fprint(w, "\"", (time.Duration(seconds) * time.Second).String(), "\"") + return err + }) + bz, err = encoder.Marshal(msg) + require.NoError(t, err) + require.Equal(t, `{"duration":"1s"}`, string(bz)) +} + +func TestWithAJson(t *testing.T) { + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{}) + + // list + msg := &testpb.WithAJson{ + Field1: []byte(`[{"name":"child1"}]`), + } + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + require.Equal(t, `{"field1":[{"name":"child1"}]}`, string(bz)) + + // string + msg = &testpb.WithAJson{ + Field1: []byte(`"hello again"`), + } + bz, err = encoder.Marshal(msg) + require.NoError(t, err) + require.Equal(t, `{"field1":"hello again"}`, string(bz)) + + // object + msg = &testpb.WithAJson{ + Field1: []byte(`{"deeper":{"nesting":1}}`), + } + bz, err = encoder.Marshal(msg) + require.NoError(t, err) + require.Equal(t, `{"field1":{"deeper":{"nesting":1}}}`, string(bz)) +} + +func TestIndent(t *testing.T) { + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{Indent: " "}) + + msg := &testpb.ABitOfEverything{ + Message: &testpb.NestedMessage{ + Foo: "test", + Bar: 0, // this is the default value and should be omitted from output + }, + Enum: testpb.AnEnum_ONE, + Repeated: []int32{3, -7, 2, 6, 4}, + Str: `abcxyz"foo"def`, + Bool: true, + Bytes: []byte{0, 1, 2, 3}, + I32: -15, + F32: 1001, + U32: 1200, + Si32: -376, + Sf32: -1000, + I64: 14578294827584932, + F64: 9572348124213523654, + U64: 4759492485, + Si64: -59268425823934, + Sf64: -659101379604211154, + } + + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + fmt.Println(string(bz)) + require.Equal(t, `{ + "type": "ABitOfEverything", + "value": { + "bool": true, + "bytes": "AAECAw==", + "enum": 1, + "f32": 1001, + "f64": "9572348124213523654", + "i32": -15, + "i64": "14578294827584932", + "message": { + "foo": "test" + }, + "repeated": [ + 3, + -7, + 2, + 6, + 4 + ], + "sf32": -1000, + "sf64": "-659101379604211154", + "si32": -376, + "si64": "-59268425823934", + "str": "abcxyz\"foo\"def", + "u32": 1200, + "u64": "4759492485" + } +}`, string(bz)) +} + +func TestEnumAsString(t *testing.T) { + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{Indent: " ", EnumAsString: true}) + + msg := &testpb.ABitOfEverything{ + Message: &testpb.NestedMessage{ + Foo: "test", + Bar: 0, // this is the default value and should be omitted from output + }, + Enum: testpb.AnEnum_ONE, + Repeated: []int32{3, -7, 2, 6, 4}, + Str: `abcxyz"foo"def`, + Bool: true, + Bytes: []byte{0, 1, 2, 3}, + I32: -15, + F32: 1001, + U32: 1200, + Si32: -376, + Sf32: -1000, + I64: 14578294827584932, + F64: 9572348124213523654, + U64: 4759492485, + Si64: -59268425823934, + Sf64: -659101379604211154, + } + + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + fmt.Println(string(bz)) + require.Equal(t, `{ + "type": "ABitOfEverything", + "value": { + "bool": true, + "bytes": "AAECAw==", + "enum": "ONE", + "f32": 1001, + "f64": "9572348124213523654", + "i32": -15, + "i64": "14578294827584932", + "message": { + "foo": "test" + }, + "repeated": [ + 3, + -7, + 2, + 6, + 4 + ], + "sf32": -1000, + "sf64": "-659101379604211154", + "si32": -376, + "si64": "-59268425823934", + "str": "abcxyz\"foo\"def", + "u32": 1200, + "u64": "4759492485" + } +}`, string(bz)) +} + +func TestAminoNameAsTypeURL(t *testing.T) { + encoder := aminojson.NewEncoder(aminojson.EncoderOptions{Indent: " ", AminoNameAsTypeURL: true}) + + msg := &testpb.ABitOfEverything{ + Message: &testpb.NestedMessage{ + Foo: "test", + Bar: 0, // this is the default value and should be omitted from output + }, + Enum: testpb.AnEnum_ONE, + Repeated: []int32{3, -7, 2, 6, 4}, + Str: `abcxyz"foo"def`, + Bool: true, + Bytes: []byte{0, 1, 2, 3}, + I32: -15, + F32: 1001, + U32: 1200, + Si32: -376, + Sf32: -1000, + I64: 14578294827584932, + F64: 9572348124213523654, + U64: 4759492485, + Si64: -59268425823934, + Sf64: -659101379604211154, + } + + bz, err := encoder.Marshal(msg) + require.NoError(t, err) + fmt.Println(string(bz)) + require.Equal(t, `{ + "type": "/testpb.ABitOfEverything", + "value": { + "bool": true, + "bytes": "AAECAw==", + "enum": 1, + "f32": 1001, + "f64": "9572348124213523654", + "i32": -15, + "i64": "14578294827584932", + "message": { + "foo": "test" + }, + "repeated": [ + 3, + -7, + 2, + 6, + 4 + ], + "sf32": -1000, + "sf64": "-659101379604211154", + "si32": -376, + "si64": "-59268425823934", + "str": "abcxyz\"foo\"def", + "u32": 1200, + "u64": "4759492485" + } +}`, string(bz)) +} diff --git a/x/tx/signing/aminojson/options.go b/x/tx/signing/aminojson/options.go new file mode 100644 index 000000000000..cf9110aef3ae --- /dev/null +++ b/x/tx/signing/aminojson/options.go @@ -0,0 +1,118 @@ +package aminojson + +import ( + cosmos_proto "github.com/cosmos/cosmos-proto" + gogoproto "github.com/cosmos/gogoproto/proto" + "github.com/iancoleman/strcase" + "github.com/pkg/errors" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + + "cosmossdk.io/api/amino" +) + +// getMessageAminoName returns the amino name of a message if it has been set by the `amino.name` option. +// If the message does not have an amino name, then the function returns false. +func getMessageAminoName(msg protoreflect.Message) (string, bool) { + messageOptions := msg.Descriptor().Options() + if proto.HasExtension(messageOptions, amino.E_Name) { + name := proto.GetExtension(messageOptions, amino.E_Name) + return name.(string), true + } + + return "", false +} + +// getMessageAminoName returns the amino name of a message if it has been set by the `amino.name` option. +// If the message does not have an amino name, then it returns the msg url. +func getMessageAminoNameAny(msg protoreflect.Message) string { + messageOptions := msg.Descriptor().Options() + if proto.HasExtension(messageOptions, amino.E_Name) { + name := proto.GetExtension(messageOptions, amino.E_Name) + return name.(string) + } + + return getMessageTypeURL(msg) +} + +// getMessageTypeURL returns the msg url. +func getMessageTypeURL(msg protoreflect.Message) string { + msgURL := "/" + string(msg.Descriptor().FullName()) + if msgURL != "/" { + return msgURL + } + + if m, ok := msg.(gogoproto.Message); ok { + return "/" + gogoproto.MessageName(m) + } + + return "" +} + +// omitEmpty returns true if the field should be omitted if empty. Empty field omission is the default behavior. +func omitEmpty(field protoreflect.FieldDescriptor) bool { + opts := field.Options() + if proto.HasExtension(opts, amino.E_DontOmitempty) { + dontOmitEmpty := proto.GetExtension(opts, amino.E_DontOmitempty).(bool) + return !dontOmitEmpty + } + return true +} + +// getAminoFieldName returns the amino field name of a field if it has been set by the `amino.field_name` option. +// If the field does not have an amino field name, then the function returns the protobuf field name. +func getAminoFieldName(field protoreflect.FieldDescriptor) string { + opts := field.Options() + if proto.HasExtension(opts, amino.E_FieldName) { + return proto.GetExtension(opts, amino.E_FieldName).(string) + } + return string(field.Name()) +} + +func getOneOfNames(field protoreflect.FieldDescriptor) (string, string, error) { + opts := field.Options() + oneOf := field.ContainingOneof() + if oneOf == nil { + return "", "", errors.Errorf("field %s must be within a oneof", field.Name()) + } + + fieldName := strcase.ToCamel(string(oneOf.Name())) + var typeName string + + if proto.HasExtension(opts, amino.E_OneofName) { + typeName = proto.GetExtension(opts, amino.E_OneofName).(string) + } else { + return "", "", errors.Errorf("field %s within a oneof must have the amino.oneof_type_name option set", + field.Name()) + } + + return fieldName, typeName, nil +} + +func (enc Encoder) getMessageEncoder(message protoreflect.Message) MessageEncoder { + opts := message.Descriptor().Options() + if proto.HasExtension(opts, amino.E_MessageEncoding) { + encoding := proto.GetExtension(opts, amino.E_MessageEncoding).(string) + if fn, ok := enc.aminoMessageEncoders[encoding]; ok { + return fn + } + } + return nil +} + +func (enc Encoder) getFieldEncoding(field protoreflect.FieldDescriptor) FieldEncoder { + opts := field.Options() + if proto.HasExtension(opts, amino.E_Encoding) { + encoding := proto.GetExtension(opts, amino.E_Encoding).(string) + if fn, ok := enc.aminoFieldEncoders[encoding]; ok { + return fn + } + } + if proto.HasExtension(opts, cosmos_proto.E_Scalar) { + scalar := proto.GetExtension(opts, cosmos_proto.E_Scalar).(string) + if fn, ok := enc.cosmosProtoScalarEncoders[scalar]; ok { + return fn + } + } + return nil +}