From 65ea305336c0da689ecf5f8c864d0f2e0370c71e Mon Sep 17 00:00:00 2001 From: Alessio Treglia Date: Mon, 8 Jun 2020 17:19:29 +0200 Subject: [PATCH] x/auth: add sign-batch command (#6350) 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 increments the sequence number automatically. Author: @jgimeno Reviewed-by: @alessio --- CHANGELOG.md | 1 + Makefile | 8 +- simapp/cmd/simcli/main.go | 1 + x/auth/client/cli/cli_test.go | 51 +++++++++++ x/auth/client/cli/tx.go | 1 + x/auth/client/cli/tx_sign.go | 135 +++++++++++++++++++++++++++++- x/auth/client/testutil/helpers.go | 8 ++ x/auth/client/tx.go | 35 ++++++++ x/auth/client/tx_test.go | 51 ++++++++++- 9 files changed, 285 insertions(+), 6 deletions(-) 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{}, "")