diff --git a/CHANGELOG.md b/CHANGELOG.md index 674e57a761f1..f1b2d55d2c10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Features +* (cli) [\#11738](https://github.com/cosmos/cosmos-sdk/pull/11738) Add `tx auth multi-sign` as alias of `tx auth multisign` for consistency with `multi-send`. +* (cli) [\#11738](https://github.com/cosmos/cosmos-sdk/pull/11738) Add `tx bank multi-send` command for bulk send of coins to multiple accounts. * (grpc) [\#11642](https://github.com/cosmos/cosmos-sdk/pull/11642) Implement `ABCIQuery` in the Tendermint gRPC service, which proxies ABCI `Query` requests directly to the application. * (x/upgrade) [\#11551](https://github.com/cosmos/cosmos-sdk/pull/11551) Update `ScheduleUpgrade` for chains to schedule an automated upgrade on `BeginBlock` without having to go though governance. * (cli) [\#11548](https://github.com/cosmos/cosmos-sdk/pull/11548) Add Tendermint's `inspect` command to the `tendermint` sub-command. diff --git a/types/coin.go b/types/coin.go index c0ad2ca0cfb5..b6cf93813a8b 100644 --- a/types/coin.go +++ b/types/coin.go @@ -410,6 +410,71 @@ func (coins Coins) SafeSub(coinsB ...Coin) (Coins, bool) { return diff, diff.IsAnyNegative() } +// MulInt performs the scalar multiplication of coins with a `multiplier` +// All coins are multipled by x +// e.g. +// {2A, 3B} * 2 = {4A, 6B} +// {2A} * 0 panics +// Note, if IsValid was true on Coins, IsValid stays true. +func (coins Coins) MulInt(x Int) Coins { + coins, ok := coins.SafeMulInt(x) + if !ok { + panic("multiplying by zero is an invalid operation on coins") + } + + return coins +} + +// SafeMulInt performs the same arithmetic as MulInt but returns false +// if the `multiplier` is zero because it makes IsValid return false. +func (coins Coins) SafeMulInt(x Int) (Coins, bool) { + if x.IsZero() { + return nil, false + } + + res := make(Coins, len(coins)) + for i, coin := range coins { + coin := coin + res[i] = NewCoin(coin.Denom, coin.Amount.Mul(x)) + } + + return res, true +} + +// QuoInt performs the scalar division of coins with a `divisor` +// All coins are divided by x and trucated. +// e.g. +// {2A, 30B} / 2 = {1A, 15B} +// {2A} / 2 = {1A} +// {4A} / {8A} = {0A} +// {2A} / 0 = panics +// Note, if IsValid was true on Coins, IsValid stays true, +// unless the `divisor` is greater than the smallest coin amount. +func (coins Coins) QuoInt(x Int) Coins { + coins, ok := coins.SafeQuoInt(x) + if !ok { + panic("dividing by zero is an invalid operation on coins") + } + + return coins +} + +// SafeQuoInt performs the same arithmetic as QuoInt but returns an error +// if the division cannot be done. +func (coins Coins) SafeQuoInt(x Int) (Coins, bool) { + if x.IsZero() { + return nil, false + } + + var res Coins + for _, coin := range coins { + coin := coin + res = append(res, NewCoin(coin.Denom, coin.Amount.Quo(x))) + } + + return res, true +} + // Max takes two valid Coins inputs and returns a valid Coins result // where for every denom D, AmountOf(D) of the result is the maximum // of AmountOf(D) of the inputs. Note that the result might be not diff --git a/types/coin_test.go b/types/coin_test.go index 83c12143078e..f405457bc8bf 100644 --- a/types/coin_test.go +++ b/types/coin_test.go @@ -18,7 +18,7 @@ var ( type coinTestSuite struct { suite.Suite - ca0, ca1, ca2, cm0, cm1, cm2 sdk.Coin + ca0, ca1, ca2, ca4, cm0, cm1, cm2, cm4 sdk.Coin } func TestCoinTestSuite(t *testing.T) { @@ -30,8 +30,10 @@ func (s *coinTestSuite) SetupSuite() { zero := sdk.NewInt(0) one := sdk.OneInt() two := sdk.NewInt(2) - s.ca0, s.ca1, s.ca2 = sdk.Coin{testDenom1, zero}, sdk.Coin{testDenom1, one}, sdk.Coin{testDenom1, two} - s.cm0, s.cm1, s.cm2 = sdk.Coin{testDenom2, zero}, sdk.Coin{testDenom2, one}, sdk.Coin{testDenom2, two} + four := sdk.NewInt(4) + + s.ca0, s.ca1, s.ca2, s.ca4 = sdk.NewCoin(testDenom1, zero), sdk.NewCoin(testDenom1, one), sdk.NewCoin(testDenom1, two), sdk.NewCoin(testDenom1, four) + s.cm0, s.cm1, s.cm2, s.cm4 = sdk.NewCoin(testDenom2, zero), sdk.NewCoin(testDenom2, one), sdk.NewCoin(testDenom2, two), sdk.NewCoin(testDenom2, four) } // ---------------------------------------------------------------------------- @@ -224,6 +226,58 @@ func (s *coinTestSuite) TestSubCoinAmount() { } } +func (s *coinTestSuite) TestMulIntCoins() { + testCases := []struct { + input sdk.Coins + multiplier sdk.Int + expected sdk.Coins + shouldPanic bool + }{ + {sdk.Coins{s.ca2}, sdk.NewInt(0), sdk.Coins{s.ca0}, true}, + {sdk.Coins{s.ca2}, sdk.NewInt(2), sdk.Coins{s.ca4}, false}, + {sdk.Coins{s.ca1, s.cm2}, sdk.NewInt(2), sdk.Coins{s.ca2, s.cm4}, false}, + } + + assert := s.Assert() + for i, tc := range testCases { + tc := tc + if tc.shouldPanic { + assert.Panics(func() { tc.input.MulInt(tc.multiplier) }) + } else { + res := tc.input.MulInt(tc.multiplier) + assert.True(res.IsValid()) + assert.Equal(tc.expected, res, "multiplication of coins is incorrect, tc #%d", i) + } + } +} + +func (s *coinTestSuite) TestQuoIntCoins() { + testCases := []struct { + input sdk.Coins + divisor sdk.Int + expected sdk.Coins + isValid bool + shouldPanic bool + }{ + {sdk.Coins{s.ca2, s.ca1}, sdk.NewInt(0), sdk.Coins{s.ca0, s.ca0}, true, true}, + {sdk.Coins{s.ca2}, sdk.NewInt(4), sdk.Coins{s.ca0}, false, false}, + {sdk.Coins{s.ca2, s.cm4}, sdk.NewInt(2), sdk.Coins{s.ca1, s.cm2}, true, false}, + {sdk.Coins{s.ca4}, sdk.NewInt(2), sdk.Coins{s.ca2}, true, false}, + } + + assert := s.Assert() + for i, tc := range testCases { + tc := tc + if tc.shouldPanic { + assert.Panics(func() { tc.input.QuoInt(tc.divisor) }) + } else { + res := tc.input.QuoInt(tc.divisor) + assert.Equal(tc.isValid, res.IsValid()) + assert.Equal(tc.expected, res, "quotient of coins is incorrect, tc #%d", i) + } + } +} + func (s *coinTestSuite) TestIsGTECoin() { cases := []struct { inputOne sdk.Coin diff --git a/x/auth/client/cli/tx_multisign.go b/x/auth/client/cli/tx_multisign.go index 9c0432df7956..3a3334334001 100644 --- a/x/auth/client/cli/tx_multisign.go +++ b/x/auth/client/cli/tx_multisign.go @@ -32,8 +32,9 @@ type BroadcastReq struct { // GetSignCommand returns the sign command func GetMultiSignCommand() *cobra.Command { cmd := &cobra.Command{ - Use: "multisign [file] [name] [[signature]...]", - Short: "Generate multisig signatures for transactions generated offline", + Use: "multi-sign [file] [name] [[signature]...]", + Aliases: []string{"multisign"}, + Short: "Generate multisig signatures for transactions generated offline", Long: strings.TrimSpace( fmt.Sprintf(`Sign transactions created with the --generate-only flag that require multisig signatures. diff --git a/x/bank/client/cli/tx.go b/x/bank/client/cli/tx.go index 87d6bfac4f70..5deb617b8952 100644 --- a/x/bank/client/cli/tx.go +++ b/x/bank/client/cli/tx.go @@ -1,6 +1,8 @@ package cli import ( + "fmt" + "github.com/spf13/cobra" "github.com/cosmos/cosmos-sdk/client" @@ -10,6 +12,8 @@ import ( "github.com/cosmos/cosmos-sdk/x/bank/types" ) +var FlagSplit = "split" + // NewTxCmd returns a root CLI command handler for all x/bank transaction commands. func NewTxCmd() *cobra.Command { txCmd := &cobra.Command{ @@ -20,7 +24,10 @@ func NewTxCmd() *cobra.Command { RunE: client.ValidateCmd, } - txCmd.AddCommand(NewSendTxCmd()) + txCmd.AddCommand( + NewSendTxCmd(), + NewMultiSendTxCmd(), + ) return txCmd } @@ -28,10 +35,12 @@ func NewTxCmd() *cobra.Command { // NewSendTxCmd returns a CLI command handler for creating a MsgSend transaction. func NewSendTxCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "send [from_key_or_address] [to_address] [amount]", - Short: `Send funds from one account to another. - Note, the '--from' flag is ignored as it is implied from [from_key_or_address]. - When using '--dry-run' a key name cannot be used, only a bech32 address.`, + Use: "send [from_key_or_address] [to_address] [amount]", + Short: "Send funds from one account to another.", + Long: `Send funds from one account to another. +Note, the '--from' flag is ignored as it is implied from [from_key_or_address]. +When using '--dry-run' a key name cannot be used, only a bech32 address. +`, Args: cobra.ExactArgs(3), RunE: func(cmd *cobra.Command, args []string) error { cmd.Flags().Set(flags.FlagFrom, args[0]) @@ -60,3 +69,77 @@ func NewSendTxCmd() *cobra.Command { return cmd } + +// NewMultiSendTxCmd returns a CLI command handler for creating a MsgMultiSend transaction. +// For a better UX this command is limited to send funds from one account to two or more accounts. +func NewMultiSendTxCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "multi-send [from_key_or_address] [to_address_1, to_address_2, ...] [amount]", + Short: "Send funds from one account to two or more accounts.", + Long: `Send funds from one account to two or more accounts. +By default, sends the [amount] to each address of the list. +Using the '--split' flag, the [amount] is split equally between the addresses. +Note, the '--from' flag is ignored as it is implied from [from_key_or_address]. +When using '--dry-run' a key name cannot be used, only a bech32 address. +`, + Args: cobra.MinimumNArgs(4), + RunE: func(cmd *cobra.Command, args []string) error { + cmd.Flags().Set(flags.FlagFrom, args[0]) + clientCtx, err := client.GetClientTxContext(cmd) + if err != nil { + return err + } + + coins, err := sdk.ParseCoinsNormalized(args[len(args)-1]) + if err != nil { + return err + } + + if coins.IsZero() { + return fmt.Errorf("must send positive amount") + } + + split, err := cmd.Flags().GetBool(FlagSplit) + if err != nil { + return err + } + + totalAddrs := sdk.NewInt(int64(len(args) - 2)) + // coins to be received by the addresses + sendCoins := coins + if split { + sendCoins = coins.QuoInt(totalAddrs) + } + + var output []types.Output + for _, arg := range args[1 : len(args)-1] { + toAddr, err := sdk.AccAddressFromBech32(arg) + if err != nil { + return err + } + + output = append(output, types.NewOutput(toAddr, sendCoins)) + } + + // amount to be send from the from address + var amount sdk.Coins + if split { + // user input: 1000stake to send to 3 addresses + // actual: 333stake to each address (=> 999stake actually sent) + amount = sendCoins.MulInt(totalAddrs) + } else { + amount = coins.MulInt(totalAddrs) + } + + msg := types.NewMsgMultiSend([]types.Input{types.NewInput(clientCtx.FromAddress, amount)}, output) + + return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg) + }, + } + + cmd.Flags().Bool(FlagSplit, false, "Send the equally split token amount to each address") + + flags.AddTxFlagsToCmd(cmd) + + return cmd +} diff --git a/x/bank/client/testutil/cli_helpers.go b/x/bank/client/testutil/cli_helpers.go index a1a33c4f67bc..60a240f4731b 100644 --- a/x/bank/client/testutil/cli_helpers.go +++ b/x/bank/client/testutil/cli_helpers.go @@ -8,6 +8,7 @@ import ( "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/testutil" clitestutil "github.com/cosmos/cosmos-sdk/testutil/cli" + sdk "github.com/cosmos/cosmos-sdk/types" bankcli "github.com/cosmos/cosmos-sdk/x/bank/client/cli" ) @@ -18,6 +19,18 @@ func MsgSendExec(clientCtx client.Context, from, to, amount fmt.Stringer, extraA return clitestutil.ExecTestCLICmd(clientCtx, bankcli.NewSendTxCmd(), args) } +func MsgMultiSendExec(clientCtx client.Context, from sdk.AccAddress, to []sdk.AccAddress, amount fmt.Stringer, extraArgs ...string) (testutil.BufferWriter, error) { + args := []string{from.String()} + for _, addr := range to { + args = append(args, addr.String()) + } + + args = append(args, amount.String()) + args = append(args, extraArgs...) + + return clitestutil.ExecTestCLICmd(clientCtx, bankcli.NewMultiSendTxCmd(), args) +} + func QueryBalancesExec(clientCtx client.Context, address fmt.Stringer, extraArgs ...string) (testutil.BufferWriter, error) { args := []string{address.String(), fmt.Sprintf("--%s=json", cli.OutputFlag)} args = append(args, extraArgs...) diff --git a/x/bank/client/testutil/cli_test.go b/x/bank/client/testutil/cli_test.go index dd36a6af2d3e..1035ca750264 100644 --- a/x/bank/client/testutil/cli_test.go +++ b/x/bank/client/testutil/cli_test.go @@ -1,3 +1,4 @@ +//go:build norace // +build norace package testutil diff --git a/x/bank/client/testutil/suite.go b/x/bank/client/testutil/suite.go index 539dfc5f3039..2c6c79316ffe 100644 --- a/x/bank/client/testutil/suite.go +++ b/x/bank/client/testutil/suite.go @@ -471,6 +471,7 @@ func (s *IntegrationTestSuite) TestNewSendTxCmd() { for _, tc := range testCases { tc := tc + s.Require().NoError(s.network.WaitForNextBlock()) s.Run(tc.name, func() { clientCtx := val.ClientCtx @@ -488,6 +489,141 @@ func (s *IntegrationTestSuite) TestNewSendTxCmd() { } } +func (s *IntegrationTestSuite) TestNewMultiSendTxCmd() { + val := s.network.Validators[0] + testAddr := sdk.AccAddress("cosmos139f7kncmglres2nf3h4hc4tade85ekfr8sulz5") + + testCases := []struct { + name string + from sdk.AccAddress + to []sdk.AccAddress + amount sdk.Coins + args []string + expectErr bool + expectedCode uint32 + respType proto.Message + }{ + { + "valid transaction", + val.Address, + []sdk.AccAddress{val.Address, testAddr}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), + }, + false, 0, &sdk.TxResponse{}, + }, + { + "valid split transaction", + val.Address, + []sdk.AccAddress{val.Address, testAddr}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", cli.FlagSplit), + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), + }, + false, 0, &sdk.TxResponse{}, + }, + { + "not enough arguments", + val.Address, + []sdk.AccAddress{val.Address}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), + }, + true, 0, &sdk.TxResponse{}, + }, + { + "chain-id shouldn't be used with offline and generate-only flags", + val.Address, + []sdk.AccAddress{val.Address, testAddr}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), + fmt.Sprintf("--%s=true", flags.FlagOffline), + fmt.Sprintf("--%s=true", flags.FlagGenerateOnly), + }, + true, 0, &sdk.TxResponse{}, + }, + { + "not enough fees", + val.Address, + []sdk.AccAddress{val.Address, testAddr}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(1))).String()), + }, + false, + sdkerrors.ErrInsufficientFee.ABCICode(), + &sdk.TxResponse{}, + }, + { + "not enough gas", + val.Address, + []sdk.AccAddress{val.Address, testAddr}, + sdk.NewCoins( + sdk.NewCoin(fmt.Sprintf("%stoken", val.Moniker), sdk.NewInt(10)), + sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10)), + ), + []string{ + fmt.Sprintf("--%s=true", flags.FlagSkipConfirmation), + fmt.Sprintf("--%s=%s", flags.FlagBroadcastMode, flags.BroadcastSync), + fmt.Sprintf("--%s=%s", flags.FlagFees, sdk.NewCoins(sdk.NewCoin(s.cfg.BondDenom, sdk.NewInt(10))).String()), + "--gas=10", + }, + false, + sdkerrors.ErrOutOfGas.ABCICode(), + &sdk.TxResponse{}, + }, + } + + for _, tc := range testCases { + tc := tc + + s.Require().NoError(s.network.WaitForNextBlock()) + s.Run(tc.name, func() { + clientCtx := val.ClientCtx + + bz, err := MsgMultiSendExec(clientCtx, tc.from, tc.to, tc.amount, tc.args...) + if tc.expectErr { + s.Require().Error(err) + } else { + s.Require().NoError(err) + + s.Require().NoError(clientCtx.Codec.UnmarshalJSON(bz.Bytes(), tc.respType), bz.String()) + txResp := tc.respType.(*sdk.TxResponse) + s.Require().Equal(tc.expectedCode, txResp.Code) + } + }) + } +} + func NewCoin(denom string, amount sdk.Int) *sdk.Coin { coin := sdk.NewCoin(denom, amount) return &coin diff --git a/x/bank/types/msgs_test.go b/x/bank/types/msgs_test.go index d11f78711591..8a22d187893e 100644 --- a/x/bank/types/msgs_test.go +++ b/x/bank/types/msgs_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/require" sdk "github.com/cosmos/cosmos-sdk/types" + types "github.com/cosmos/cosmos-sdk/types" ) func TestMsgSendRoute(t *testing.T) { @@ -184,24 +185,46 @@ func TestMsgMultiSendValidation(t *testing.T) { {false, MsgMultiSend{}}, // no input or output {false, MsgMultiSend{Inputs: []Input{input1}}}, // just input {false, MsgMultiSend{Outputs: []Output{output1}}}, // just output - {false, MsgMultiSend{ - Inputs: []Input{NewInput(emptyAddr, atom123)}, // invalid input - Outputs: []Output{output1}}}, - {false, MsgMultiSend{ - Inputs: []Input{input1}, - Outputs: []Output{{emptyAddr.String(), atom123}}}, // invalid output + { + false, + MsgMultiSend{ + Inputs: []Input{NewInput(emptyAddr, atom123)}, // invalid input + Outputs: []Output{output1}}, }, - {false, MsgMultiSend{ - Inputs: []Input{input1}, - Outputs: []Output{output2}}, // amounts dont match + { + false, + MsgMultiSend{ + Inputs: []Input{input1}, + Outputs: []Output{{emptyAddr.String(), atom123}}, // invalid output + }, }, - {true, MsgMultiSend{ - Inputs: []Input{input1}, - Outputs: []Output{output1}}, + { + false, + MsgMultiSend{ + Inputs: []Input{input1}, + Outputs: []Output{output2}, // amounts dont match + }, }, - {true, MsgMultiSend{ - Inputs: []Input{input1, input2}, - Outputs: []Output{outputMulti}}, + { + true, + MsgMultiSend{ + Inputs: []Input{input1}, + Outputs: []Output{output1}, + }, + }, + { + true, + MsgMultiSend{ + Inputs: []Input{input1, input2}, + Outputs: []Output{outputMulti}, + }, + }, + { + true, + MsgMultiSend{ + Inputs: []Input{NewInput(addr2, atom123.MulInt(types.NewInt(2)))}, + Outputs: []Output{output1, output1}, + }, }, }