diff --git a/CHANGELOG.md b/CHANGELOG.md index 88999132f9..bf65a4dd13 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -149,6 +149,7 @@ be used to retrieve the actual proposal `Content`. Also the `NewMsgSubmitProposa * (x/capability) [\#5828](https://github.com/cosmos/cosmos-sdk/pull/5828) Capability module integration as outlined in [ADR 3 - Dynamic Capability Store](https://github.com/cosmos/tree/master/docs/architecture/adr-003-dynamic-capability-store.md). * (x/params) [\#6005](https://github.com/cosmos/cosmos-sdk/pull/6005) Add new CLI command for querying raw x/params parameters by subspace and key. * (x/ibc) [\#5769](https://github.com/cosmos/cosmos-sdk/pull/5769) [ICS 009 - Loopback Client](https://github.com/cosmos/ics/tree/master/spec/ics-009-loopback-client) subpackage +* (x/auth) [\6350](https://github.com/cosmos/cosmos-sdk/pull/6350) New sign-batch command to sign StdTx batch files. ### Bug Fixes diff --git a/Makefile b/Makefile index 5841acd8cb..e8e3f62e7d 100644 --- a/Makefile +++ b/Makefile @@ -48,13 +48,17 @@ mocks: $(MOCKS_DIR) $(MOCKS_DIR): mkdir -p $(MOCKS_DIR) -distclean: +distclean: clean rm -rf \ gitian-build-darwin/ \ gitian-build-linux/ \ gitian-build-windows/ \ .gitian-builder-cache/ -.PHONY: distclean + +clean: + rm -rf $(BUILDDIR)/ + +.PHONY: distclean clean ############################################################################### ### Tools & Dependencies ### diff --git a/simapp/cmd/simcli/main.go b/simapp/cmd/simcli/main.go index 234e549887..ee849b9189 100644 --- a/simapp/cmd/simcli/main.go +++ b/simapp/cmd/simcli/main.go @@ -132,6 +132,7 @@ func txCmd(cdc *codec.Codec) *cobra.Command { bankcmd.NewSendTxCmd(clientCtx), flags.LineBreak, authcmd.GetSignCommand(cdc), + authcmd.GetSignBatchCommand(cdc), authcmd.GetMultiSignCommand(cdc), authcmd.GetValidateSignaturesCommand(cdc), flags.LineBreak, diff --git a/x/auth/client/cli/cli_test.go b/x/auth/client/cli/cli_test.go index 66a7cc9f28..b831ee348a 100644 --- a/x/auth/client/cli/cli_test.go +++ b/x/auth/client/cli/cli_test.go @@ -73,6 +73,57 @@ func TestCLIValidateSignatures(t *testing.T) { f.Cleanup() } +func TestCLISignBatch(t *testing.T) { + t.Parallel() + f := cli.InitFixtures(t) + + fooAddr := f.KeyAddress(cli.KeyFoo) + barAddr := f.KeyAddress(cli.KeyBar) + + sendTokens := sdk.TokensFromConsensusPower(10) + success, generatedStdTx, stderr := bankcli.TxSend(f, fooAddr.String(), barAddr, sdk.NewCoin(cli.Denom, sendTokens), "--generate-only") + + require.True(t, success) + require.Empty(t, stderr) + + // Write the output to disk + batchfile, cleanup1 := tests.WriteToNewTempFile(t, strings.Repeat(generatedStdTx, 3)) + t.Cleanup(cleanup1) + + // sign-batch file - offline is set but account-number and sequence are not + success, _, stderr = testutil.TxSignBatch(f, cli.KeyFoo, batchfile.Name(), "--offline") + require.Contains(t, stderr, "required flag(s) \"account-number\", \"sequence\" not set") + require.False(t, success) + + // sign-batch file + success, stdout, stderr := testutil.TxSignBatch(f, cli.KeyFoo, batchfile.Name()) + require.True(t, success) + require.Empty(t, stderr) + require.Equal(t, 3, len(strings.Split(strings.Trim(stdout, "\n"), "\n"))) + + // sign-batch file + success, stdout, stderr = testutil.TxSignBatch(f, cli.KeyFoo, batchfile.Name(), "--signature-only") + require.True(t, success) + require.Empty(t, stderr) + require.Equal(t, 3, len(strings.Split(strings.Trim(stdout, "\n"), "\n"))) + + malformedFile, cleanup2 := tests.WriteToNewTempFile(t, fmt.Sprintf("%smalformed", generatedStdTx)) + t.Cleanup(cleanup2) + + // sign-batch file + success, stdout, stderr = testutil.TxSignBatch(f, cli.KeyFoo, malformedFile.Name()) + require.False(t, success) + require.Equal(t, 1, len(strings.Split(strings.Trim(stdout, "\n"), "\n"))) + require.Equal(t, "ERROR: cannot parse disfix JSON wrapper: invalid character 'm' looking for beginning of value\n", stderr) + + // sign-batch file + success, stdout, _ = testutil.TxSignBatch(f, cli.KeyFoo, malformedFile.Name(), "--signature-only") + require.False(t, success) + require.Equal(t, 1, len(strings.Split(strings.Trim(stdout, "\n"), "\n"))) + + f.Cleanup() +} + func TestCLISendGenerateSignAndBroadcast(t *testing.T) { t.Parallel() f := cli.InitFixtures(t) diff --git a/x/auth/client/cli/tx.go b/x/auth/client/cli/tx.go index 742ab35471..132d61ed30 100644 --- a/x/auth/client/cli/tx.go +++ b/x/auth/client/cli/tx.go @@ -21,6 +21,7 @@ func GetTxCmd(cdc *codec.Codec) *cobra.Command { GetMultiSignCommand(cdc), GetSignCommand(cdc), GetValidateSignaturesCommand(cdc), + GetSignBatchCommand(cdc), ) return txCmd } diff --git a/x/auth/client/cli/tx_sign.go b/x/auth/client/cli/tx_sign.go index adc3ef8a29..dbeabf0560 100644 --- a/x/auth/client/cli/tx_sign.go +++ b/x/auth/client/cli/tx_sign.go @@ -1,16 +1,18 @@ package cli import ( + "bufio" "fmt" "os" "github.com/spf13/cobra" "github.com/spf13/viper" + "github.com/cosmos/cosmos-sdk/client" "github.com/cosmos/cosmos-sdk/client/flags" "github.com/cosmos/cosmos-sdk/codec" sdk "github.com/cosmos/cosmos-sdk/types" - "github.com/cosmos/cosmos-sdk/x/auth/client" + authclient "github.com/cosmos/cosmos-sdk/x/auth/client" "github.com/cosmos/cosmos-sdk/x/auth/types" ) @@ -20,6 +22,133 @@ const ( flagSigOnly = "signature-only" ) +// GetSignBatchCommand returns the transaction sign-batch command. +func GetSignBatchCommand(codec *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "sign-batch [file]", + Short: "Sign transaction batch files", + Long: `Sign batch files of transactions generated with --generate-only. +The command processes list of transactions from file (one StdTx each line), generate +signed transactions or signatures and print their JSON encoding, delimited by '\n'. +As the signatures are generated, the command updates the sequence number accordingly. + +If the flag --signature-only flag is set, it will output a JSON representation +of the generated signature only. + +The --offline flag makes sure that the client will not reach out to full node. +As a result, the account and the sequence number queries will not be performed and +it is required to set such parameters manually. Note, invalid values will cause +the transaction to fail. The sequence will be incremented automatically for each +transaction that is signed. + +The --multisig= flag generates a signature on behalf of a multisig +account key. It implies --signature-only. +`, + PreRun: preSignCmd, + RunE: makeSignBatchCmd(codec), + Args: cobra.ExactArgs(1), + } + + cmd.Flags().String( + flagMultisig, "", + "Address of the multisig account on behalf of which the transaction shall be signed", + ) + cmd.Flags().String(flags.FlagOutputDocument, "", "The document will be written to the given file instead of STDOUT") + cmd.Flags().Bool(flagSigOnly, true, "Print only the generated signature, then exit") + cmd = flags.PostCommands(cmd)[0] + cmd.MarkFlagRequired(flags.FlagFrom) + + return cmd +} + +func makeSignBatchCmd(cdc *codec.Codec) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + inBuf := bufio.NewReader(cmd.InOrStdin()) + clientCtx := client.NewContextWithInput(inBuf).WithCodec(cdc) + txBldr := types.NewTxBuilderFromCLI(inBuf) + generateSignatureOnly := viper.GetBool(flagSigOnly) + + var ( + err error + multisigAddr sdk.AccAddress + infile = os.Stdin + ) + + // validate multisig address if there's any + if viper.GetString(flagMultisig) != "" { + multisigAddr, err = sdk.AccAddressFromBech32(viper.GetString(flagMultisig)) + if err != nil { + return err + } + } + + // prepare output document + closeFunc, err := setOutputFile(cmd) + if err != nil { + return err + } + + defer closeFunc() + clientCtx.WithOutput(cmd.OutOrStdout()) + + if args[0] != "-" { + infile, err = os.Open(args[0]) + if err != nil { + return err + } + } + + scanner := authclient.NewBatchScanner(cdc, infile) + + for sequence := txBldr.Sequence(); scanner.Scan(); sequence++ { + var stdTx types.StdTx + + unsignedStdTx := scanner.StdTx() + txBldr = txBldr.WithSequence(sequence) + + if multisigAddr.Empty() { + stdTx, err = authclient.SignStdTx(txBldr, clientCtx, viper.GetString(flags.FlagFrom), unsignedStdTx, false, true) + } else { + stdTx, err = authclient.SignStdTxWithSignerAddress(txBldr, clientCtx, multisigAddr, clientCtx.GetFromName(), unsignedStdTx, true) + } + + if err != nil { + return err + } + + json, err := getSignatureJSON(cdc, stdTx, clientCtx.Indent, generateSignatureOnly) + if err != nil { + return err + } + + cmd.Printf("%s\n", json) + } + + if err := scanner.UnmarshalErr(); err != nil { + return err + } + + return scanner.Err() + } +} + +func setOutputFile(cmd *cobra.Command) (func(), error) { + outputDoc := viper.GetString(flags.FlagOutputDocument) + if outputDoc == "" { + cmd.SetOut(cmd.OutOrStdout()) + return func() {}, nil + } + + fp, err := os.OpenFile(outputDoc, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + return func() {}, err + } + + cmd.SetOut(fp) + + return func() { fp.Close() }, nil +} + // GetSignCommand returns the transaction sign command. func GetSignCommand(codec *codec.Codec) *cobra.Command { cmd := &cobra.Command{ @@ -89,13 +218,13 @@ func makeSignCmd(cdc *codec.Codec) func(cmd *cobra.Command, args []string) error if err != nil { return err } - newTx, err = client.SignStdTxWithSignerAddress( + newTx, err = authclient.SignStdTxWithSignerAddress( txBldr, clientCtx, multisigAddr, clientCtx.GetFromName(), stdTx, clientCtx.Offline, ) generateSignatureOnly = true } else { appendSig := viper.GetBool(flagAppend) && !generateSignatureOnly - newTx, err = client.SignStdTx(txBldr, clientCtx, clientCtx.GetFromName(), stdTx, appendSig, clientCtx.Offline) + newTx, err = authclient.SignStdTx(txBldr, clientCtx, clientCtx.GetFromName(), stdTx, appendSig, clientCtx.Offline) } if err != nil { diff --git a/x/auth/client/testutil/helpers.go b/x/auth/client/testutil/helpers.go index 20faccf95a..d04c51b9db 100644 --- a/x/auth/client/testutil/helpers.go +++ b/x/auth/client/testutil/helpers.go @@ -44,3 +44,11 @@ func TxMultisign(f *cli.Fixtures, fileName, name string, signaturesFiles []strin ) return cli.ExecuteWriteRetStdStreams(f.T, cli.AddFlags(cmd, flags)) } + +func TxSignBatch(f *cli.Fixtures, signer, fileName string, flags ...string) (bool, string, string) { + cmd := fmt.Sprintf("%s tx sign-batch %v --keyring-backend=test --from=%s %v", f.SimcliBinary, f.Flags(), signer, fileName) + + return cli.ExecuteWriteRetStdStreams(f.T, cli.AddFlags(cmd, flags), clientkeys.DefaultKeyPass) +} + +// DONTCOVER diff --git a/x/auth/client/tx.go b/x/auth/client/tx.go index a503854e75..d8dfb531b0 100644 --- a/x/auth/client/tx.go +++ b/x/auth/client/tx.go @@ -4,6 +4,7 @@ import ( "bufio" "bytes" "fmt" + "io" "io/ioutil" "os" "strings" @@ -250,6 +251,40 @@ func ReadStdTxFromFile(cdc *codec.Codec, filename string) (stdTx authtypes.StdTx return } +// NewBatchScanner returns a new BatchScanner to read newline-delimited StdTx transactions from r. +func NewBatchScanner(cdc *codec.Codec, r io.Reader) *BatchScanner { + return &BatchScanner{Scanner: bufio.NewScanner(r), cdc: cdc} +} + +// BatchScanner provides a convenient interface for reading batch data such as a file +// of newline-delimited JSON encoded StdTx. +type BatchScanner struct { + *bufio.Scanner + stdTx authtypes.StdTx + cdc *codec.Codec + unmarshalErr error +} + +// StdTx returns the most recent StdTx unmarshalled by a call to Scan. +func (bs BatchScanner) StdTx() authtypes.StdTx { return bs.stdTx } + +// UnmarshalErr returns the first unmarshalling error that was encountered by the scanner. +func (bs BatchScanner) UnmarshalErr() error { return bs.unmarshalErr } + +// Scan advances the Scanner to the next line. +func (bs *BatchScanner) Scan() bool { + if !bs.Scanner.Scan() { + return false + } + + if err := bs.cdc.UnmarshalJSON(bs.Bytes(), &bs.stdTx); err != nil && bs.unmarshalErr == nil { + bs.unmarshalErr = err + return false + } + + return true +} + func populateAccountFromState( txBldr authtypes.TxBuilder, clientCtx client.Context, addr sdk.AccAddress, ) (authtypes.TxBuilder, error) { diff --git a/x/auth/client/tx_test.go b/x/auth/client/tx_test.go index 740f87499b..3e4bd6adec 100644 --- a/x/auth/client/tx_test.go +++ b/x/auth/client/tx_test.go @@ -5,10 +5,10 @@ import ( "errors" "io/ioutil" "os" + "strings" "testing" "github.com/stretchr/testify/require" - "github.com/tendermint/tendermint/crypto/ed25519" "github.com/cosmos/cosmos-sdk/codec" @@ -117,6 +117,7 @@ func TestConfiguredTxEncoder(t *testing.T) { } func TestReadStdTxFromFile(t *testing.T) { + t.Parallel() cdc := codec.New() sdk.RegisterCodec(cdc) @@ -135,6 +136,54 @@ func TestReadStdTxFromFile(t *testing.T) { require.Equal(t, decodedTx.Memo, "foomemo") } +func TestBatchScanner_Scan(t *testing.T) { + t.Parallel() + cdc := codec.New() + sdk.RegisterCodec(cdc) + + batch1 := `{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"150"}],"gas":"50000"},"signatures":[],"memo":"foomemo"} +{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"150"}],"gas":"10000"},"signatures":[],"memo":"foomemo"} +{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"1"}],"gas":"10000"},"signatures":[],"memo":"foomemo"} +` + batch2 := `{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"150"}],"gas":"50000"},"signatures":[],"memo":"foomemo"} +malformed +{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"1"}],"gas":"10000"},"signatures":[],"memo":"foomemo"} +` + batch3 := `{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"150"}],"gas":"50000"},"signatures":[],"memo":"foomemo"} +{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"1"}],"gas":"10000"},"signatures":[],"memo":"foomemo"}` + batch4 := `{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"150"}],"gas":"50000"},"signatures":[],"memo":"foomemo"} + +{"msg":[],"fee":{"amount":[{"denom":"atom","amount":"1"}],"gas":"10000"},"signatures":[],"memo":"foomemo"} +` + tests := []struct { + name string + batch string + wantScannerError bool + wantUnmarshalError bool + numTxs int + }{ + {"good batch", batch1, false, false, 3}, + {"malformed", batch2, false, true, 1}, + {"missing trailing newline", batch3, false, false, 2}, + {"empty line", batch4, false, true, 1}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + scanner, i := NewBatchScanner(cdc, strings.NewReader(tt.batch)), 0 + for scanner.Scan() { + _ = scanner.StdTx() + i++ + } + + require.Equal(t, tt.wantScannerError, scanner.Err() != nil) + require.Equal(t, tt.wantUnmarshalError, scanner.UnmarshalErr() != nil) + require.Equal(t, tt.numTxs, i) + }) + } +} + func compareEncoders(t *testing.T, expected sdk.TxEncoder, actual sdk.TxEncoder) { msgs := []sdk.Msg{sdk.NewTestMsg(addr)} tx := authtypes.NewStdTx(msgs, authtypes.StdFee{}, []authtypes.StdSignature{}, "")