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 5 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.
* (cli) [#13207](https://github.com/cosmos/cosmos-sdk/pull/13207) Reduce user's password prompts when calling keyring `List()` function
* (x/authz) [#12648](https://github.com/cosmos/cosmos-sdk/pull/12648) Add an allow list, an optional list of addresses allowed to receive bank assets via authz MsgSend grant.
* (sdk.Coins) [#12627](https://github.com/cosmos/cosmos-sdk/pull/12627) Make a Denoms method on sdk.Coins.
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
8 changes: 8 additions & 0 deletions core/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,30 @@ go 1.18
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-alpha7
github.com/stretchr/testify v1.8.0
google.golang.org/protobuf v1.28.1
gotest.tools/v3 v3.3.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.8 // 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-20220726230323-06994584191e // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
golang.org/x/text v0.3.7 // indirect
google.golang.org/genproto v0.0.0-20220725144611-272f38e5d71b // indirect
google.golang.org/grpc v1.49.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace cosmossdk.io/math => ../math
6 changes: 6 additions & 0 deletions core/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ 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-alpha7 h1:yqYUOHF2jopwZh4dVQp3xgqwftE5/2hkrwIV6vkUbO0=
github.com/cosmos/cosmos-proto v1.0.0-alpha7/go.mod h1:dosO4pSAbJF8zWCzCoTWP7nNsjcvSUBQmniFxDg5daw=
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=
Expand All @@ -31,8 +33,11 @@ github.com/regen-network/gocuke v0.6.2 h1:pHviZ0kKAq2U2hN2q3smKNxct6hS0mGByFMHGn
github.com/rogpeppe/go-internal v1.8.1 h1:geMPLpDpQOgVyCg5z5GoRwLHepNdb71NXb67XFkP+Eg=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
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.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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 @@ -79,6 +84,7 @@ 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.3.0 h1:MfDY1b1/0xN1CyMlQDac0ziEy9zJQd9CXBRRDHw2jJo=
gotest.tools/v3 v3.3.0/go.mod h1:Mcr9QNxkg0uMvy/YElmo4SpXgJKWgQvYrT7Kw5RzJ1A=
pgregory.net/rapid v0.4.7/go.mod h1:UYpPVyjFHzYBGHIxLFoupi8vwk6rXNzRY9OMvVxFIOU=
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