feat(aminojson): expose NullSliceAsEmptyEncoder and add null_slice_as_empty encoding option (#25539)

Co-authored-by: Alex | Cosmos Labs <alex@cosmoslabs.io>
This commit is contained in:
forkfury 2025-11-14 17:12:40 +01:00 committed by GitHub
parent 203ac3e464
commit ce5656f38f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 190 additions and 4 deletions

View File

@ -43,6 +43,7 @@ This patch update also includes minor dependency bumps.
### Features
* (abci_utils) [#25008](https://github.com/cosmos/cosmos-sdk/pull/24861) add the ability to assign a custom signer extraction adapter in `DefaultProposalHandler`.
* (x/tx) [#25539](https://github.com/cosmos/cosmos-sdk/pull/25539) Expose `NullSliceAsEmptyEncoder` as a public function and add `null_slice_as_empty` encoding option for protobuf annotations.
## [v0.53.3](https://github.com/cosmos/cosmos-sdk/releases/tag/v0.53.3) - 2025-07-08

View File

@ -122,8 +122,19 @@ https://github.com/cosmos/cosmos-sdk/blob/e8f28bf5db18b8d6b7e0d94b542ce4cf48fed9
Encoding instructs the amino json marshaler how to encode certain fields that may differ from the standard encoding behaviour. The most common example of this is how `repeated cosmos.base.v1beta1.Coin` is encoded when using the amino json encoding format. The `legacy_coins` option tells the json marshaler [how to encode a null slice](https://github.com/cosmos/cosmos-sdk/blob/e8f28bf5db18b8d6b7e0d94b542ce4cf48fed9d6/x/tx/signing/aminojson/json_marshal.go#L65) of `cosmos.base.v1beta1.Coin`.
For a more generic option that works with any slice type, you can use `null_slice_as_empty`, which ensures that nil slices are encoded as empty arrays (`[]`) instead of `null`. This is useful for maintaining backward compatibility with legacy Amino JSON encoding where nil slices were serialized as empty arrays.
Alternatively, you can use the exported `NullSliceAsEmptyEncoder` function directly in your code:
```go
encoder := aminojson.NewEncoder(options)
encoder = encoder.DefineFieldEncoding("my_field", aminojson.NullSliceAsEmptyEncoder)
```
```proto
(amino.encoding) = "legacy_coins",
// or for a more generic option:
(amino.encoding) = "null_slice_as_empty",
```
```proto reference

View File

@ -234,3 +234,90 @@ func TestNewSignModeHandler(t *testing.T) {
})
require.NotNil(t, handler)
}
func TestNullSliceAsEmptyEncoder(t *testing.T) {
encoder := aminojson.NewEncoder(aminojson.EncoderOptions{})
testCases := []struct {
name string
amount []*basev1beta1.Coin
wantJSON string
description string
}{
{
name: "empty slice encodes as empty array",
amount: []*basev1beta1.Coin{},
wantJSON: `[]`,
description: "Empty slice should be encoded as [] not null",
},
{
name: "nil slice encodes as empty array",
amount: nil,
wantJSON: `[]`,
description: "Nil slice should be encoded as [] not null",
},
{
name: "non-empty slice encodes normally",
amount: []*basev1beta1.Coin{
{Denom: "uatom", Amount: "1000"},
{Denom: "stake", Amount: "500"},
},
wantJSON: "", // Will check content instead of exact match
description: "Non-empty slice should encode normally",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
fee := &txv1beta1.Fee{
Amount: tc.amount,
}
// Test that the encoder works with legacy_coins (which uses NullSliceAsEmptyEncoder)
bz, err := encoder.Marshal(fee)
require.NoError(t, err)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(bz, &result))
amountJSON, err := json.Marshal(result["amount"])
require.NoError(t, err)
if tc.wantJSON == "" {
// For non-empty slices, just verify it's a valid array with expected content
require.Contains(t, string(amountJSON), "uatom")
require.Contains(t, string(amountJSON), "stake")
require.Contains(t, string(amountJSON), "1000")
require.Contains(t, string(amountJSON), "500")
} else {
require.Equal(t, tc.wantJSON, string(amountJSON), tc.description)
}
})
}
}
func TestNullSliceAsEmptyEncoderDirect(t *testing.T) {
encoder := aminojson.NewEncoder(aminojson.EncoderOptions{})
// Test direct usage of NullSliceAsEmptyEncoder with a custom field encoding
customEncoder := encoder.DefineFieldEncoding("test_field", aminojson.NullSliceAsEmptyEncoder)
require.NotNil(t, customEncoder)
// Create a Fee message with an empty list (Fee uses legacy_coins which uses NullSliceAsEmptyEncoder)
fee := &txv1beta1.Fee{
Amount: []*basev1beta1.Coin{}, // empty slice
}
// Marshal using the encoder
bz, err := encoder.Marshal(fee)
require.NoError(t, err)
var result map[string]interface{}
require.NoError(t, json.Unmarshal(bz, &result))
// Verify that amount field exists and is an empty array (not null)
amount, ok := result["amount"]
require.True(t, ok, "amount field should exist")
amountJSON, err := json.Marshal(amount)
require.NoError(t, err)
require.Equal(t, `[]`, string(amountJSON), "empty slice should be encoded as [] not null")
}

View File

@ -72,9 +72,22 @@ func cosmosDecEncoder(_ *Encoder, v protoreflect.Value, w io.Writer) error {
}
}
// nullSliceAsEmptyEncoder replicates the behavior at:
// NullSliceAsEmptyEncoder replicates the behavior at:
// https://github.com/cosmos/cosmos-sdk/blob/be9bd7a8c1b41b115d58f4e76ee358e18a52c0af/types/coin.go#L199-L205
func nullSliceAsEmptyEncoder(enc *Encoder, v protoreflect.Value, w io.Writer) error {
//
// This encoder ensures that nil slices are encoded as empty arrays ([]) instead of null.
// This is useful for maintaining backward compatibility with legacy Amino JSON encoding
// where nil slices were serialized as empty arrays.
//
// Usage example:
//
// encoder := aminojson.NewEncoder(options)
// encoder = encoder.DefineFieldEncoding("my_field", aminojson.NullSliceAsEmptyEncoder)
//
// Or in protobuf:
//
// repeated MyType field = 1 [(amino.encoding) = "null_slice_as_empty"];
func NullSliceAsEmptyEncoder(enc *Encoder, v protoreflect.Value, w io.Writer) error {
switch list := v.Interface().(type) {
case protoreflect.List:
if list.Len() == 0 {

View File

@ -7,6 +7,9 @@ import (
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/reflect/protoreflect"
"gotest.tools/v3/assert"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
txv1beta1 "cosmossdk.io/api/cosmos/tx/v1beta1"
)
func TestCosmosInlineJSON(t *testing.T) {
@ -172,3 +175,73 @@ func TestSortedJSONStringify(t *testing.T) {
})
}
}
func TestNullSliceAsEmptyEncoder(t *testing.T) {
encoder := NewEncoder(EncoderOptions{})
t.Run("empty list encodes as empty array", func(t *testing.T) {
// Create a Fee message with empty amount list
fee := &txv1beta1.Fee{
Amount: []*basev1beta1.Coin{}, // empty slice
}
// Get the list value from the message
msg := fee.ProtoReflect()
field := msg.Descriptor().Fields().ByName("amount")
listValue := msg.Get(field)
// Test the encoder function directly
var buf bytes.Buffer
err := NullSliceAsEmptyEncoder(&encoder, listValue, &buf)
require.NoError(t, err)
assert.Equal(t, "[]", buf.String(), "Empty list should encode as [] not null")
})
t.Run("nil list encodes as empty array", func(t *testing.T) {
// Create a Fee message with nil amount
fee := &txv1beta1.Fee{
Amount: nil, // nil slice
}
// Get the list value from the message
msg := fee.ProtoReflect()
field := msg.Descriptor().Fields().ByName("amount")
listValue := msg.Get(field)
// Test the encoder function directly
var buf bytes.Buffer
err := NullSliceAsEmptyEncoder(&encoder, listValue, &buf)
require.NoError(t, err)
assert.Equal(t, "[]", buf.String(), "Nil list should encode as [] not null")
})
t.Run("non-empty list encodes normally", func(t *testing.T) {
// Create a Fee message with non-empty amount list
fee := &txv1beta1.Fee{
Amount: []*basev1beta1.Coin{
{Denom: "uatom", Amount: "1000"},
},
}
// Get the list value from the message
msg := fee.ProtoReflect()
field := msg.Descriptor().Fields().ByName("amount")
listValue := msg.Get(field)
// Test the encoder function directly
var buf bytes.Buffer
err := NullSliceAsEmptyEncoder(&encoder, listValue, &buf)
require.NoError(t, err)
// Should encode the list normally (not just [])
require.Contains(t, buf.String(), "uatom")
require.Contains(t, buf.String(), "1000")
})
t.Run("unsupported type returns error", func(t *testing.T) {
// Test with unsupported type
var buf bytes.Buffer
err := NullSliceAsEmptyEncoder(&encoder, protoreflect.ValueOfString("not a list"), &buf)
require.Error(t, err)
require.Contains(t, err.Error(), "unsupported type")
})
}

View File

@ -82,8 +82,9 @@ func NewEncoder(options EncoderOptions) Encoder {
"threshold_string": thresholdStringEncoder,
},
aminoFieldEncoders: map[string]FieldEncoder{
"legacy_coins": nullSliceAsEmptyEncoder,
"inline_json": cosmosInlineJSON,
"legacy_coins": NullSliceAsEmptyEncoder,
"null_slice_as_empty": NullSliceAsEmptyEncoder,
"inline_json": cosmosInlineJSON,
},
protoTypeEncoders: map[string]MessageEncoder{
"google.protobuf.Timestamp": marshalTimestamp,