Skip to content

Commit 7e4d5d7

Browse files
authored
feat: implement multi-send transaction command (backport #11738) (#11830)
1 parent a8a5d82 commit 7e4d5d7

File tree

9 files changed

+403
-24
lines changed

9 files changed

+403
-24
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Ref: https://keepachangelog.com/en/1.0.0/
3939

4040
### Features
4141

42+
* (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`.
43+
* (cli) [\#11738](https://github.com/cosmos/cosmos-sdk/pull/11738) Add `tx bank multi-send` command for bulk send of coins to multiple accounts.
4244
* (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.
4345
* (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.
4446
* (cli) [\#11548](https://github.com/cosmos/cosmos-sdk/pull/11548) Add Tendermint's `inspect` command to the `tendermint` sub-command.

types/coin.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,71 @@ func (coins Coins) SafeSub(coinsB Coins) (Coins, bool) {
400400
return diff, diff.IsAnyNegative()
401401
}
402402

403+
// MulInt performs the scalar multiplication of coins with a `multiplier`
404+
// All coins are multipled by x
405+
// e.g.
406+
// {2A, 3B} * 2 = {4A, 6B}
407+
// {2A} * 0 panics
408+
// Note, if IsValid was true on Coins, IsValid stays true.
409+
func (coins Coins) MulInt(x Int) Coins {
410+
coins, ok := coins.SafeMulInt(x)
411+
if !ok {
412+
panic("multiplying by zero is an invalid operation on coins")
413+
}
414+
415+
return coins
416+
}
417+
418+
// SafeMulInt performs the same arithmetic as MulInt but returns false
419+
// if the `multiplier` is zero because it makes IsValid return false.
420+
func (coins Coins) SafeMulInt(x Int) (Coins, bool) {
421+
if x.IsZero() {
422+
return nil, false
423+
}
424+
425+
res := make(Coins, len(coins))
426+
for i, coin := range coins {
427+
coin := coin
428+
res[i] = NewCoin(coin.Denom, coin.Amount.Mul(x))
429+
}
430+
431+
return res, true
432+
}
433+
434+
// QuoInt performs the scalar division of coins with a `divisor`
435+
// All coins are divided by x and trucated.
436+
// e.g.
437+
// {2A, 30B} / 2 = {1A, 15B}
438+
// {2A} / 2 = {1A}
439+
// {4A} / {8A} = {0A}
440+
// {2A} / 0 = panics
441+
// Note, if IsValid was true on Coins, IsValid stays true,
442+
// unless the `divisor` is greater than the smallest coin amount.
443+
func (coins Coins) QuoInt(x Int) Coins {
444+
coins, ok := coins.SafeQuoInt(x)
445+
if !ok {
446+
panic("dividing by zero is an invalid operation on coins")
447+
}
448+
449+
return coins
450+
}
451+
452+
// SafeQuoInt performs the same arithmetic as QuoInt but returns an error
453+
// if the division cannot be done.
454+
func (coins Coins) SafeQuoInt(x Int) (Coins, bool) {
455+
if x.IsZero() {
456+
return nil, false
457+
}
458+
459+
var res Coins
460+
for _, coin := range coins {
461+
coin := coin
462+
res = append(res, NewCoin(coin.Denom, coin.Amount.Quo(x)))
463+
}
464+
465+
return res, true
466+
}
467+
403468
// Max takes two valid Coins inputs and returns a valid Coins result
404469
// where for every denom D, AmountOf(D) of the result is the maximum
405470
// of AmountOf(D) of the inputs. Note that the result might be not

types/coin_test.go

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ var (
1818

1919
type coinTestSuite struct {
2020
suite.Suite
21-
ca0, ca1, ca2, cm0, cm1, cm2 sdk.Coin
21+
ca0, ca1, ca2, ca4, cm0, cm1, cm2, cm4 sdk.Coin
2222
}
2323

2424
func TestCoinTestSuite(t *testing.T) {
@@ -30,8 +30,10 @@ func (s *coinTestSuite) SetupSuite() {
3030
zero := sdk.NewInt(0)
3131
one := sdk.OneInt()
3232
two := sdk.NewInt(2)
33-
s.ca0, s.ca1, s.ca2 = sdk.Coin{testDenom1, zero}, sdk.Coin{testDenom1, one}, sdk.Coin{testDenom1, two}
34-
s.cm0, s.cm1, s.cm2 = sdk.Coin{testDenom2, zero}, sdk.Coin{testDenom2, one}, sdk.Coin{testDenom2, two}
33+
four := sdk.NewInt(4)
34+
35+
s.ca0, s.ca1, s.ca2, s.ca4 = sdk.NewCoin(testDenom1, zero), sdk.NewCoin(testDenom1, one), sdk.NewCoin(testDenom1, two), sdk.NewCoin(testDenom1, four)
36+
s.cm0, s.cm1, s.cm2, s.cm4 = sdk.NewCoin(testDenom2, zero), sdk.NewCoin(testDenom2, one), sdk.NewCoin(testDenom2, two), sdk.NewCoin(testDenom2, four)
3537
}
3638

3739
// ----------------------------------------------------------------------------
@@ -224,6 +226,58 @@ func (s *coinTestSuite) TestSubCoinAmount() {
224226
}
225227
}
226228

229+
func (s *coinTestSuite) TestMulIntCoins() {
230+
testCases := []struct {
231+
input sdk.Coins
232+
multiplier sdk.Int
233+
expected sdk.Coins
234+
shouldPanic bool
235+
}{
236+
{sdk.Coins{s.ca2}, sdk.NewInt(0), sdk.Coins{s.ca0}, true},
237+
{sdk.Coins{s.ca2}, sdk.NewInt(2), sdk.Coins{s.ca4}, false},
238+
{sdk.Coins{s.ca1, s.cm2}, sdk.NewInt(2), sdk.Coins{s.ca2, s.cm4}, false},
239+
}
240+
241+
assert := s.Assert()
242+
for i, tc := range testCases {
243+
tc := tc
244+
if tc.shouldPanic {
245+
assert.Panics(func() { tc.input.MulInt(tc.multiplier) })
246+
} else {
247+
res := tc.input.MulInt(tc.multiplier)
248+
assert.True(res.IsValid())
249+
assert.Equal(tc.expected, res, "multiplication of coins is incorrect, tc #%d", i)
250+
}
251+
}
252+
}
253+
254+
func (s *coinTestSuite) TestQuoIntCoins() {
255+
testCases := []struct {
256+
input sdk.Coins
257+
divisor sdk.Int
258+
expected sdk.Coins
259+
isValid bool
260+
shouldPanic bool
261+
}{
262+
{sdk.Coins{s.ca2, s.ca1}, sdk.NewInt(0), sdk.Coins{s.ca0, s.ca0}, true, true},
263+
{sdk.Coins{s.ca2}, sdk.NewInt(4), sdk.Coins{s.ca0}, false, false},
264+
{sdk.Coins{s.ca2, s.cm4}, sdk.NewInt(2), sdk.Coins{s.ca1, s.cm2}, true, false},
265+
{sdk.Coins{s.ca4}, sdk.NewInt(2), sdk.Coins{s.ca2}, true, false},
266+
}
267+
268+
assert := s.Assert()
269+
for i, tc := range testCases {
270+
tc := tc
271+
if tc.shouldPanic {
272+
assert.Panics(func() { tc.input.QuoInt(tc.divisor) })
273+
} else {
274+
res := tc.input.QuoInt(tc.divisor)
275+
assert.Equal(tc.isValid, res.IsValid())
276+
assert.Equal(tc.expected, res, "quotient of coins is incorrect, tc #%d", i)
277+
}
278+
}
279+
}
280+
227281
func (s *coinTestSuite) TestIsGTECoin() {
228282
cases := []struct {
229283
inputOne sdk.Coin

x/auth/client/cli/tx_multisign.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,9 @@ type BroadcastReq struct {
3232
// GetSignCommand returns the sign command
3333
func GetMultiSignCommand() *cobra.Command {
3434
cmd := &cobra.Command{
35-
Use: "multisign [file] [name] [[signature]...]",
36-
Short: "Generate multisig signatures for transactions generated offline",
35+
Use: "multi-sign [file] [name] [[signature]...]",
36+
Aliases: []string{"multisign"},
37+
Short: "Generate multisig signatures for transactions generated offline",
3738
Long: strings.TrimSpace(
3839
fmt.Sprintf(`Sign transactions created with the --generate-only flag that require multisig signatures.
3940

x/bank/client/cli/tx.go

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package cli
22

33
import (
4+
"fmt"
5+
46
"github.com/spf13/cobra"
57

68
"github.com/cosmos/cosmos-sdk/client"
@@ -10,6 +12,8 @@ import (
1012
"github.com/cosmos/cosmos-sdk/x/bank/types"
1113
)
1214

15+
var FlagSplit = "split"
16+
1317
// NewTxCmd returns a root CLI command handler for all x/bank transaction commands.
1418
func NewTxCmd() *cobra.Command {
1519
txCmd := &cobra.Command{
@@ -20,17 +24,23 @@ func NewTxCmd() *cobra.Command {
2024
RunE: client.ValidateCmd,
2125
}
2226

23-
txCmd.AddCommand(NewSendTxCmd())
27+
txCmd.AddCommand(
28+
NewSendTxCmd(),
29+
NewMultiSendTxCmd(),
30+
)
2431

2532
return txCmd
2633
}
2734

2835
// NewSendTxCmd returns a CLI command handler for creating a MsgSend transaction.
2936
func NewSendTxCmd() *cobra.Command {
3037
cmd := &cobra.Command{
31-
Use: "send [from_key_or_address] [to_address] [amount]",
32-
Short: `Send funds from one account to another. Note, the'--from' flag is
33-
ignored as it is implied from [from_key_or_address].`,
38+
Use: "send [from_key_or_address] [to_address] [amount]",
39+
Short: "Send funds from one account to another.",
40+
Long: `Send funds from one account to another.
41+
Note, the '--from' flag is ignored as it is implied from [from_key_or_address].
42+
When using '--dry-run' a key name cannot be used, only a bech32 address.
43+
`,
3444
Args: cobra.ExactArgs(3),
3545
RunE: func(cmd *cobra.Command, args []string) error {
3646
cmd.Flags().Set(flags.FlagFrom, args[0])
@@ -58,3 +68,77 @@ ignored as it is implied from [from_key_or_address].`,
5868

5969
return cmd
6070
}
71+
72+
// NewMultiSendTxCmd returns a CLI command handler for creating a MsgMultiSend transaction.
73+
// For a better UX this command is limited to send funds from one account to two or more accounts.
74+
func NewMultiSendTxCmd() *cobra.Command {
75+
cmd := &cobra.Command{
76+
Use: "multi-send [from_key_or_address] [to_address_1, to_address_2, ...] [amount]",
77+
Short: "Send funds from one account to two or more accounts.",
78+
Long: `Send funds from one account to two or more accounts.
79+
By default, sends the [amount] to each address of the list.
80+
Using the '--split' flag, the [amount] is split equally between the addresses.
81+
Note, the '--from' flag is ignored as it is implied from [from_key_or_address].
82+
When using '--dry-run' a key name cannot be used, only a bech32 address.
83+
`,
84+
Args: cobra.MinimumNArgs(4),
85+
RunE: func(cmd *cobra.Command, args []string) error {
86+
cmd.Flags().Set(flags.FlagFrom, args[0])
87+
clientCtx, err := client.GetClientTxContext(cmd)
88+
if err != nil {
89+
return err
90+
}
91+
92+
coins, err := sdk.ParseCoinsNormalized(args[len(args)-1])
93+
if err != nil {
94+
return err
95+
}
96+
97+
if coins.IsZero() {
98+
return fmt.Errorf("must send positive amount")
99+
}
100+
101+
split, err := cmd.Flags().GetBool(FlagSplit)
102+
if err != nil {
103+
return err
104+
}
105+
106+
totalAddrs := sdk.NewInt(int64(len(args) - 2))
107+
// coins to be received by the addresses
108+
sendCoins := coins
109+
if split {
110+
sendCoins = coins.QuoInt(totalAddrs)
111+
}
112+
113+
var output []types.Output
114+
for _, arg := range args[1 : len(args)-1] {
115+
toAddr, err := sdk.AccAddressFromBech32(arg)
116+
if err != nil {
117+
return err
118+
}
119+
120+
output = append(output, types.NewOutput(toAddr, sendCoins))
121+
}
122+
123+
// amount to be send from the from address
124+
var amount sdk.Coins
125+
if split {
126+
// user input: 1000stake to send to 3 addresses
127+
// actual: 333stake to each address (=> 999stake actually sent)
128+
amount = sendCoins.MulInt(totalAddrs)
129+
} else {
130+
amount = coins.MulInt(totalAddrs)
131+
}
132+
133+
msg := types.NewMsgMultiSend([]types.Input{types.NewInput(clientCtx.FromAddress, amount)}, output)
134+
135+
return tx.GenerateOrBroadcastTxCLI(clientCtx, cmd.Flags(), msg)
136+
},
137+
}
138+
139+
cmd.Flags().Bool(FlagSplit, false, "Send the equally split token amount to each address")
140+
141+
flags.AddTxFlagsToCmd(cmd)
142+
143+
return cmd
144+
}

x/bank/client/testutil/cli_helpers.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/cosmos/cosmos-sdk/client"
99
"github.com/cosmos/cosmos-sdk/testutil"
1010
clitestutil "github.com/cosmos/cosmos-sdk/testutil/cli"
11+
sdk "github.com/cosmos/cosmos-sdk/types"
1112
bankcli "github.com/cosmos/cosmos-sdk/x/bank/client/cli"
1213
)
1314

@@ -18,6 +19,18 @@ func MsgSendExec(clientCtx client.Context, from, to, amount fmt.Stringer, extraA
1819
return clitestutil.ExecTestCLICmd(clientCtx, bankcli.NewSendTxCmd(), args)
1920
}
2021

22+
func MsgMultiSendExec(clientCtx client.Context, from sdk.AccAddress, to []sdk.AccAddress, amount fmt.Stringer, extraArgs ...string) (testutil.BufferWriter, error) {
23+
args := []string{from.String()}
24+
for _, addr := range to {
25+
args = append(args, addr.String())
26+
}
27+
28+
args = append(args, amount.String())
29+
args = append(args, extraArgs...)
30+
31+
return clitestutil.ExecTestCLICmd(clientCtx, bankcli.NewMultiSendTxCmd(), args)
32+
}
33+
2134
func QueryBalancesExec(clientCtx client.Context, address fmt.Stringer, extraArgs ...string) (testutil.BufferWriter, error) {
2235
args := []string{address.String(), fmt.Sprintf("--%s=json", cli.OutputFlag)}
2336
args = append(args, extraArgs...)

x/bank/client/testutil/cli_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
//go:build norace
12
// +build norace
23

34
package testutil

0 commit comments

Comments
 (0)