Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: Move FormatCoins to core #13306

Merged
merged 17 commits into from
Oct 19, 2022
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ Ref: https://keepachangelog.com/en/1.0.0/

### Features

* (core) [#13306](https://github.com/cosmos/cosmos-sdk/pull/13306) Add a `FormatCoins` function to in `core/coins` to format sdk Coins following the Value Renderers spec.
* (math) [#13306](https://github.com/cosmos/cosmos-sdk/pull/13306) Add `FormatInt` and `FormatDec` functiosn in `math` to format integers and decimals following the Value Renderers spec.
* (grpc) [#13485](https://github.com/cosmos/cosmos-sdk/pull/13485) Implement a new gRPC query, `/cosmos/base/node/v1beta1/config`, which provides operator configuration.
* (x/staking) [#13122](https://github.com/cosmos/cosmos-sdk/pull/13122) Add `UnbondingCanComplete` and `PutUnbondingOnHold` to `x/staking` module.
* [#13437](https://github.com/cosmos/cosmos-sdk/pull/13437) Add new flag `--modules-to-export` in `simd export` command to export only selected modules.
Expand Down
92 changes: 92 additions & 0 deletions core/coins/format.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package coins

import (
"fmt"
"sort"
"strings"

bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/math"
)

// formatCoin formats a sdk.Coin into a value-rendered string, using the
// given metadata about the denom. It returns the formatted coin string, the
// display denom, and an optional error.
func formatCoin(coin *basev1beta1.Coin, metadata *bankv1beta1.Metadata) (string, error) {
coinDenom := coin.Denom

// Return early if no display denom or display denom is the current coin denom.
if metadata == nil || metadata.Display == "" || coinDenom == metadata.Display {
vr, err := math.FormatDec(coin.Amount)
return vr + " " + coin.Denom, err
}

dispDenom := metadata.Display

// Find exponents of both denoms.
var coinExp, dispExp uint32
foundCoinExp, foundDispExp := false, false
for _, unit := range metadata.DenomUnits {
if coinDenom == unit.Denom {
coinExp = unit.Exponent
foundCoinExp = true
}
if dispDenom == unit.Denom {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we comparing against unit.Denom here? shouldn't it be unit.Display?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no unit.Display, the metadata object looks like

{
  "metadata": {
    "description": "The native staking token of the Cosmos Hub.",
    "denom_units": [
      {
        "denom": "uatom",
        "exponent": 0,
        "aliases": [
          "microatom"
        ]
      },
      {
        "denom": "matom",
        "exponent": 3,
        "aliases": [
          "milliatom"
        ]
      },
      {
        "denom": "atom",
        "exponent": 6,
        "aliases": [
        ]
      }
    ],
    "base": "uatom",
    "display": "atom",
    "name": "",
    "symbol": ""
  }
}

This piece of code finds the denom unit object (which contains the exponent) from inside the denom_units array

dispExp = unit.Exponent
foundDispExp = true
}
}

// If we didn't find either exponent, then we return early.
if !foundCoinExp || !foundDispExp {
vr, err := math.FormatInt(coin.Amount)
return vr + " " + coin.Denom, err
}

exponentDiff := int64(coinExp) - int64(dispExp)

Check failure

Code scanning / gosec

Potential integer overflow by integer type conversion

Potential integer overflow by integer type conversion

Check failure

Code scanning / gosec

Potential integer overflow by integer type conversion

Potential integer overflow by integer type conversion

dispAmount, err := math.LegacyNewDecFromStr(coin.Amount)
if err != nil {
return "", err
}

if exponentDiff > 0 {
dispAmount = dispAmount.Mul(math.LegacyNewDec(10).Power(uint64(exponentDiff)))

Check failure

Code scanning / gosec

Potential integer overflow by integer type conversion

Potential integer overflow by integer type conversion
} else {
dispAmount = dispAmount.Quo(math.LegacyNewDec(10).Power(uint64(-exponentDiff)))

Check failure

Code scanning / gosec

Potential integer overflow by integer type conversion

Potential integer overflow by integer type conversion
}

vr, err := math.FormatDec(dispAmount.String())
return vr + " " + dispDenom, err
}

// formatCoins formats Coins into a value-rendered string, which uses
// `formatCoin` separated by ", " (a comma and a space), and sorted
// alphabetically by value-rendered denoms. It expects an array of metadata
// (optionally nil), where each metadata at index `i` MUST match the coin denom
// at the same index.
func FormatCoins(coins []*basev1beta1.Coin, metadata []*bankv1beta1.Metadata) (string, error) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@aaronc This PR only does Format. Parsing hasnt' been implemented yet (see #13153), do you need it for AutoCLI? If so we can bump that issue up in priority.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

autocli actually only needs parsing...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok, let's still get this merged IMO. Then we can add parsing directly here.

if len(coins) != len(metadata) {
return "", fmt.Errorf("formatCoins expect one metadata for each coin; expected %d, got %d", len(coins), len(metadata))
}

formatted := make([]string, len(coins))
for i, coin := range coins {
var err error
formatted[i], err = formatCoin(coin, metadata[i])
if err != nil {
return "", err
}
}

// Sort the formatted coins by display denom.
sort.SliceStable(formatted, func(i, j int) bool {
denomI := strings.Split(formatted[i], " ")[1]
denomJ := strings.Split(formatted[j], " ")[1]

return denomI < denomJ
})

return strings.Join(formatted, ", "), nil
}
81 changes: 81 additions & 0 deletions core/coins/format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package coins_test

import (
"encoding/json"
"os"
"testing"

bankv1beta1 "cosmossdk.io/api/cosmos/bank/v1beta1"
basev1beta1 "cosmossdk.io/api/cosmos/base/v1beta1"
"cosmossdk.io/core/coins"
"github.com/stretchr/testify/require"
)

func TestFormatCoin(t *testing.T) {
var testcases []coinJsonTest
raw, err := os.ReadFile("../../tx/textual/internal/testdata/coin.json")
require.NoError(t, err)
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)

for _, tc := range testcases {
t.Run(tc.Text, func(t *testing.T) {
if tc.Proto != nil {
out, err := coins.FormatCoins([]*basev1beta1.Coin{tc.Proto}, []*bankv1beta1.Metadata{tc.Metadata})

if tc.Error {
require.Error(t, err)
return
}

require.NoError(t, err)
require.Equal(t, tc.Text, out)
}
})
}
}

func TestFormatCoins(t *testing.T) {
var testcases []coinsJsonTest
raw, err := os.ReadFile("../../tx/textual/internal/testdata/coins.json")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This core test imports a tx module's json file, which is not ideal.

We could alternatively:

  • test FormatCoins in the tx module. Con: unit test not next to its function
  • move coins.json into core. Con: we'll have some ValueRenderer json files in tx, others in core

For now the PR's version is my favorite. Any other opinions?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What you're doing is also maybe not terrible 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am going to keep it like this for now

require.NoError(t, err)
err = json.Unmarshal(raw, &testcases)
require.NoError(t, err)

for _, tc := range testcases {
t.Run(tc.Text, func(t *testing.T) {
if tc.Proto != nil {
metadata := make([]*bankv1beta1.Metadata, len(tc.Proto))
for i, coin := range tc.Proto {
metadata[i] = tc.Metadata[coin.Denom]
}

out, err := coins.FormatCoins(tc.Proto, metadata)

if tc.Error {
require.Error(t, err)
return
}

require.NoError(t, err)
require.Equal(t, tc.Text, out)
}
})
}
}

// coinsJsonTest is the type of test cases in the coin.json file.
type coinJsonTest struct {
Proto *basev1beta1.Coin
Metadata *bankv1beta1.Metadata
Text string
Error bool
}

// coinsJsonTest is the type of test cases in the coins.json file.
type coinsJsonTest struct {
Proto []*basev1beta1.Coin
Metadata map[string]*bankv1beta1.Metadata
Text string
Error bool
}
amaury1093 marked this conversation as resolved.
Show resolved Hide resolved
9 changes: 9 additions & 0 deletions core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,31 @@ go 1.19
require (
cosmossdk.io/api v0.2.1
cosmossdk.io/depinject v1.0.0-alpha.3
cosmossdk.io/math v1.0.0-beta.3
github.com/cosmos/cosmos-proto v1.0.0-alpha8
github.com/stretchr/testify v1.8.0
google.golang.org/protobuf v1.28.1
gotest.tools/v3 v3.4.0
sigs.k8s.io/yaml v1.3.0
)

require (
github.com/cosmos/gogoproto v1.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 // indirect
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220930163606-c98284e70a91 // indirect
google.golang.org/grpc v1.50.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

// temporary until we tag a new go module
replace cosmossdk.io/math => ../math
Comment on lines +34 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is kinda annoying...right? make changes, add replace directive, tag, remove replace directive (assuming you don't forget).

Can we think of a better way?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Maybe we can always keep replaces on main, and use tags in release branches? I don't know.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't do separate stuff on release branches. When we have more go.mod's release branches become much less necessary. We should usually be tagging off main.

Ideally we'd have a bot that whenever it sees a merged commit with local replace directives, opens a PR to remove them which we can merge after we tag.

10 changes: 10 additions & 0 deletions core/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@ github.com/alecthomas/participle/v2 v2.0.0-alpha7 h1:cK4vjj0VSgb3lN1nuKA5F7dw+1s
github.com/cockroachdb/apd/v3 v3.1.0 h1:MK3Ow7LH0W8zkd5GMKA1PvS9qG3bWFI95WaVNfyZJ/w=
github.com/cosmos/cosmos-proto v1.0.0-alpha8 h1:d3pCRuMYYvGA5bM0ZbbjKn+AoQD4A7dyNG2wzwWalUw=
github.com/cosmos/cosmos-proto v1.0.0-alpha8/go.mod h1:6/p+Bc4O8JKeZqe0VqUGTX31eoYqemTT4C1hLCWsO7I=
github.com/cosmos/gogoproto v1.4.1 h1:WoyH+0/jbCTzpKNvyav5FL1ZTWsp1im1MxEpJEzKUB8=
github.com/cosmos/gogoproto v1.4.1/go.mod h1:Ac9lzL4vFpBMcptJROQ6dQ4M3pOEK5Z/l0Q9p+LoCr4=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cucumber/common/gherkin/go/v22 v22.0.0 h1:4K8NqptbvdOrjL9DEea6HFjSpbdT9+Q5kgLpmmsHYl0=
github.com/cucumber/common/messages/go/v17 v17.1.1 h1:RNqopvIFyLWnKv0LfATh34SWBhXeoFTJnSrgm9cT/Ts=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gofrs/uuid v4.2.0+incompatible h1:yyYWMnhkhrKwwr8gAOcOCYxOOscHgDS9yZgBrnJfGa0=
Expand All @@ -24,9 +27,14 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/regen-network/gocuke v0.6.2 h1:pHviZ0kKAq2U2hN2q3smKNxct6hS0mGByFMHGnWA97M=
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
Expand Down Expand Up @@ -70,7 +78,9 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.4.0 h1:ZazjZUfuVeZGLAmlKKuyv3IKP5orXcwtOwDQH6YVr6o=
gotest.tools/v3 v3.4.0/go.mod h1:CtbdzLSsqVhDgMtKsx03ird5YTGB3ar27v0u/yKBW5g=
pgregory.net/rapid v0.5.3 h1:163N50IHFqr1phZens4FQOdPgfJscR7a562mjQqeo4M=
Expand Down
32 changes: 32 additions & 0 deletions math/dec.go
Original file line number Diff line number Diff line change
Expand Up @@ -894,3 +894,35 @@ func LegacyDecApproxEq(t *testing.T, d1 LegacyDec, d2 LegacyDec, tol LegacyDec)
diff := d1.Sub(d2).Abs()
return t, diff.LTE(tol), "expected |d1 - d2| <:\t%v\ngot |d1 - d2| = \t\t%v", tol.String(), diff.String()
}

// FormatDec formats a decimal (as encoded in protobuf) into a value-rendered
// string following ADR-050. This function operates with string manipulation
// (instead of manipulating the sdk.Dec object).
func FormatDec(v string) (string, error) {
aaronc marked this conversation as resolved.
Show resolved Hide resolved
parts := strings.Split(v, ".")
if len(parts) > 2 {
return "", fmt.Errorf("invalid decimal: too many points in %s", v)
}

intPart, err := FormatInt(parts[0])
if err != nil {
return "", err
}

if len(parts) == 1 {
return intPart, nil
}

decPart := strings.TrimRight(parts[1], "0")
if len(decPart) == 0 {
return intPart, nil
}

// Ensure that the decimal part has only digits.
// https://github.com/cosmos/cosmos-sdk/issues/12811
if !hasOnlyDigits(decPart) {
return "", fmt.Errorf("non-digits detected after decimal point in: %q", decPart)
}

return intPart + "." + decPart, nil
}
29 changes: 29 additions & 0 deletions math/dec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -666,3 +666,32 @@ func BenchmarkLegacyQuoRoundupMut(b *testing.B) {
}
sink = (interface{})(nil)
}

func TestFormatDecNonDigits(t *testing.T) {
badCases := []string{
"10.a",
"1a.10",
"p1a10.",
"0.10p",
"--10",
"12.😎😎",
"11111111111133333333333333333333333333333a",
"11111111111133333333333333333333333333333 192892",
}

for _, value := range badCases {
value := value
t.Run(value, func(t *testing.T) {
s, err := math.FormatDec(value)
if err == nil {
t.Fatal("Expected an error")
}
if g, w := err.Error(), "non-digits"; !strings.Contains(g, w) {
t.Errorf("Error mismatch\nGot: %q\nWant substring: %q", g, w)
}
if s != "" {
t.Fatalf("Got a non-empty string: %q", s)
}
})
}
}
42 changes: 42 additions & 0 deletions math/int.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math/big"
"strings"
"testing"
)

Expand Down Expand Up @@ -432,3 +433,44 @@ func (i *Int) UnmarshalAmino(bz []byte) error { return i.Unmarshal(bz) }
func IntEq(t *testing.T, exp, got Int) (*testing.T, bool, string, string, string) {
return t, exp.Equal(got), "expected:\t%v\ngot:\t\t%v", exp.String(), got.String()
}

func hasOnlyDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}

const thousandSeparator string = "'"

// FormatInt formats an integer (encoded as in protobuf) into a value-rendered
// string following ADR-050. This function operates with string manipulation
// (instead of manipulating the int or sdk.Int object).
func FormatInt(v string) (string, error) {
sign := ""
if v[0] == '-' {
sign = "-"
v = v[1:]
}
if len(v) > 1 {
v = strings.TrimLeft(v, "0")
}

// Ensure that the string contains only digits at this point.
if !hasOnlyDigits(v) {
return "", fmt.Errorf("expecting only digits 0-9, but got non-digits in %q", v)
}

startOffset := 3
for outputIndex := len(v); outputIndex > startOffset; {
outputIndex -= 3
v = v[:outputIndex] + thousandSeparator + v[outputIndex:]
}

return sign + v, nil
}
Loading