diff --git a/x/auth/client/cli/batch.go b/x/auth/client/cli/batch.go new file mode 100644 index 0000000000..d0fb4eec85 --- /dev/null +++ b/x/auth/client/cli/batch.go @@ -0,0 +1,101 @@ +package cli + +import ( + "fmt" + "io" + "os" + + "github.com/pkg/errors" + "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/client/keys" + "github.com/cosmos/cosmos-sdk/codec" + "github.com/cosmos/cosmos-sdk/x/auth/client/utils" + "github.com/cosmos/cosmos-sdk/x/auth/types" +) + +const ( + FlagPassPhrase = "passphrase" +) + +func GetBatchSignCommand(codec *codec.Codec) *cobra.Command { + cmd := &cobra.Command{ + Use: "sign-batch [in-file]", + Short: "Sign many standard transactions generated offline", + Long: `Sign a list of transactions created with the --generate-only flag. +It will read StdSignDoc JSONs from [in-file], one transaction per line, and +produce a file of JSON encoded StdSignatures, one per line. + +This command is intended to work offline for security purposes.`, + PreRun: preSignCmd, + RunE: makeBatchSignCmd(codec), + Args: cobra.ExactArgs(1), + } + + cmd.Flags().String(FlagPassPhrase, "", "The passphrase of the key needed to sign the transaction.") + cmd.Flags().String(client.FlagOutputDocument, "", + "write the resulto to the given file instead of the default location") + + return flags.PostCommands(cmd)[0] +} + +func makeBatchSignCmd(cdc *codec.Codec) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) error { + kb, err := keys.NewKeyBaseFromDir(viper.GetString(flags.FlagHome)) + if err != nil { + return err + } + + out, err := setOutput() + if err != nil { + return errors.Wrap(err, "error with output") + } + + from, err := kb.Get(viper.GetString(flags.FlagFrom)) + if err != nil { + return errors.Wrap(err, "key not found") + } + + txs, err := utils.ReadStdTxsFromFile(cdc, args[0]) + if err != nil { + return errors.Wrap(err, "error extracting txs from file") + } + + passphrase := viper.GetString(FlagPassPhrase) + for _, tx := range txs { + signature, err := types.MakeSignature(nil, from.GetName(), passphrase, tx) + if err != nil { + return errors.Wrap(err, fmt.Sprintf("error signing tx %d", tx.Sequence)) + } + + json, err := cdc.MarshalJSON(signature) + if err != nil { + return errors.Wrap(err, "error marshalling signature") + } + + _, err = fmt.Fprintf(out, "%s\n", json) + if err != nil { + return errors.Wrap(err, "error writing to output") + } + } + + return nil + } +} + +func setOutput() (io.Writer, error) { + outputFlag := viper.GetString(client.FlagOutputDocument) + if outputFlag == "" { + return os.Stdout, nil + } + + out, err := os.OpenFile(outputFlag, os.O_RDWR, 0644) + if err != nil { + return nil, err + } + + return out, nil +} diff --git a/x/auth/client/cli/batch_test.go b/x/auth/client/cli/batch_test.go new file mode 100644 index 0000000000..6e18ca4273 --- /dev/null +++ b/x/auth/client/cli/batch_test.go @@ -0,0 +1,237 @@ +package cli_test + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/require" + "github.com/tendermint/go-amino" + "github.com/tendermint/tendermint/crypto/multisig" + + "github.com/cosmos/cosmos-sdk/client/flags" + "github.com/cosmos/cosmos-sdk/client/keys" + "github.com/cosmos/cosmos-sdk/codec" + keys2 "github.com/cosmos/cosmos-sdk/crypto/keys" + "github.com/cosmos/cosmos-sdk/tests" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + "github.com/cosmos/cosmos-sdk/x/auth/client/cli" + "github.com/cosmos/cosmos-sdk/x/staking" + "github.com/cosmos/go-bip39" + "github.com/tendermint/tendermint/crypto" +) + +const passphrase = "012345678" + +func TestGetBatchSignCommand(t *testing.T) { + cdc := amino.NewCodec() + sdk.RegisterCodec(cdc) + codec.RegisterCrypto(cdc) + staking.RegisterCodec(cdc) + + cmd := cli.GetBatchSignCommand(cdc) + + tempDir, cleanFunc := tests.NewTestCaseDir(t) + t.Cleanup(cleanFunc) + + outputFile, err := os.Create(filepath.Join(tempDir, "the-output")) + require.NoError(t, err) + defer outputFile.Close() + + kb, _, err := createKeybaseWithMultisigAccount(tempDir) + require.NoError(t, err) + + multiInfo, err := kb.Get("multi") + require.NoError(t, err) + + viper.Reset() + viper.Set(flags.FlagHome, tempDir) + viper.Set(flags.FlagFrom, "acc1") + viper.Set(cli.FlagMultisig, multiInfo.GetAddress()) + viper.Set(cli.FlagPassPhrase, passphrase) + viper.Set(flags.FlagOutputDocument, outputFile.Name()) + + cmd.SetArgs([]string{ + "./testdata/txs.json", + }) + + err = cmd.Execute() + require.NoError(t, err) + + // Validate Result + inputFile, err := os.Open("./testdata/txs.json") + require.NoError(t, err) + + validateSignatures(t, cdc, inputFile, outputFile) +} + +func validateSignatures(t *testing.T, cdc *codec.Codec, inputFile io.Reader, outputFile io.Reader) { + inputData, err := ioutil.ReadAll(inputFile) + require.NoError(t, err) + + outputData, err := ioutil.ReadAll(outputFile) + require.NoError(t, err) + + txs := extractTxs(t, cdc, inputData) + signatures := extractSignatures(t, cdc, outputData) + + if len(txs) != len(signatures) { + t.Errorf("must be same amount of txs and signatures: '%d' txs, '%d' signatures", len(txs), len(signatures)) + } + + for i := 0; i < len(txs); i++ { + require.True(t, signatures[i].PubKey.VerifyBytes(txs[i].Bytes(), signatures[i].Signature)) + } +} + +func extractTxs(t *testing.T, cdc *codec.Codec, inputData []byte) []auth.StdSignMsg { + inputLines := strings.Split(string(inputData), "\n") + + var parsedTxs []auth.StdSignMsg + for _, txLine := range inputLines { + if len(txLine) == 0 { + break + } + + var parsedTx auth.StdSignMsg + + err := cdc.UnmarshalJSON([]byte(txLine), &parsedTx) + if err != nil { + t.Errorf("error extracting tx: %s", err) + } + + parsedTxs = append(parsedTxs, parsedTx) + } + + return parsedTxs +} + +func extractSignatures(t *testing.T, cdc *codec.Codec, outputData []byte) []auth.StdSignature { + outputLines := strings.Split(string(outputData), "\n") + + var parsedSigs []auth.StdSignature + for _, sigLine := range outputLines { + if len(sigLine) == 0 { + break + } + + var parsedSig auth.StdSignature + + err := cdc.UnmarshalJSON([]byte(sigLine), &parsedSig) + if err != nil { + t.Errorf("error extracting tx: %s", err) + } + + parsedSigs = append(parsedSigs, parsedSig) + } + + return parsedSigs +} + +func createKeybaseWithMultisigAccount(dir string) (keys2.Keybase, []crypto.PubKey, error) { + kb, err := keys.NewKeyBaseFromDir(dir) + if err != nil { + return nil, nil, err + } + + var pubKeys []crypto.PubKey + for i := 0; i < 4; i++ { + entropySeed, err := bip39.NewEntropy(256) + if err != nil { + return nil, nil, err + } + + mnemonic, err := bip39.NewMnemonic(entropySeed[:]) + if err != nil { + return nil, nil, err + } + + account, err := kb.CreateAccount( + fmt.Sprintf("acc%d", i), + mnemonic, + "", + passphrase, + 0, + 0, + ) + if err != nil { + return nil, nil, err + } + + pubKeys = append(pubKeys, account.GetPubKey()) + } + + pk := multisig.NewPubKeyMultisigThreshold(2, pubKeys) + if _, err := kb.CreateMulti("multi", pk); err != nil { + return nil, nil, err + } + + return kb, pubKeys, nil +} + +func TestGetBatchSignCommand_Error(t *testing.T) { + tts := []struct { + name string + errorContains string + keybasePrep func(tempDir string) + providedFlags map[string]interface{} + }{ + { + name: "not existing key", + errorContains: "key not found: Key not-existing not found", + keybasePrep: func(tempDir string) { + }, + providedFlags: map[string]interface{}{ + flags.FlagFrom: "not-existing", + }, + }, + { + name: "invalid passphrase", + errorContains: "invalid account password", + keybasePrep: func(tempDir string) { + createKeybaseWithMultisigAccount(tempDir) + }, + providedFlags: map[string]interface{}{ + flags.FlagFrom: "acc1", + }, + }, + } + + cdc := amino.NewCodec() + sdk.RegisterCodec(cdc) + staking.RegisterCodec(cdc) + + for _, tt := range tts { + tt := tt + tempDir, cleanFunc := tests.NewTestCaseDir(t) + + t.Run(tt.name, func(t *testing.T) { + defer cleanFunc() + + cmd := cli.GetBatchSignCommand(cdc) + + tt.keybasePrep(tempDir) + + viper.Reset() + viper.Set(flags.FlagHome, tempDir) + + for key, val := range tt.providedFlags { + viper.Set(key, val) + } + + cmd.SetArgs([]string{ + "./testdata/txs.json", + }) + + err := cmd.Execute() + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorContains) + }) + } +} diff --git a/x/auth/client/cli/testdata/txs.json b/x/auth/client/cli/testdata/txs.json new file mode 100644 index 0000000000..e332b0e5c1 --- /dev/null +++ b/x/auth/client/cli/testdata/txs.json @@ -0,0 +1,2 @@ +{"account_number":"24263","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"100000","denom":"uatom"}],"gas":"333000"},"memo":" ","msgs":[{"type":"cosmos-sdk/MsgUndelegate","value":{"amount":{"amount":"1300000000000","denom":"uatom"},"delegator_address":"cosmos176m2p8l3fps3dal7h8gf9jvrv98tu3rqfdht86","validator_address":"cosmosvaloper1qs8tnw2t8l6amtzvdemnnsq9dzk0ag0z52uzay"}}],"sequence":"37"} +{"account_number":"24263","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"100000","denom":"uatom"}],"gas":"333000"},"memo":" ","msgs":[{"type":"cosmos-sdk/MsgUndelegate","value":{"amount":{"amount":"1300000000000","denom":"uatom"},"delegator_address":"cosmos176m2p8l3fps3dal7h8gf9jvrv98tu3rqfdht86","validator_address":"cosmosvaloper13sduv92y3xdhy3rpmhakrc3v7t37e7ps9l0kpv"}}],"sequence":"38"} diff --git a/x/auth/client/cli/tx_sign.go b/x/auth/client/cli/tx_sign.go index 8ae87cb40c..3ca729dac9 100644 --- a/x/auth/client/cli/tx_sign.go +++ b/x/auth/client/cli/tx_sign.go @@ -18,7 +18,7 @@ import ( ) const ( - flagMultisig = "multisig" + FlagMultisig = "multisig" flagAppend = "append" flagValidateSigs = "validate-signatures" flagOffline = "offline" @@ -58,7 +58,7 @@ be generated via the 'multisign' command. } cmd.Flags().String( - flagMultisig, "", + FlagMultisig, "", "Address of the multisig account on behalf of which the transaction shall be signed", ) cmd.Flags().Bool( @@ -113,7 +113,7 @@ func makeSignCmd(cdc *codec.Codec) func(cmd *cobra.Command, args []string) error // if --signature-only is on, then override --append var newTx types.StdTx generateSignatureOnly := viper.GetBool(flagSigOnly) - multisigAddrStr := viper.GetString(flagMultisig) + multisigAddrStr := viper.GetString(FlagMultisig) if multisigAddrStr != "" { var multisigAddr sdk.AccAddress diff --git a/x/auth/client/utils/testdata/txs b/x/auth/client/utils/testdata/txs new file mode 100644 index 0000000000..e332b0e5c1 --- /dev/null +++ b/x/auth/client/utils/testdata/txs @@ -0,0 +1,2 @@ +{"account_number":"24263","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"100000","denom":"uatom"}],"gas":"333000"},"memo":" ","msgs":[{"type":"cosmos-sdk/MsgUndelegate","value":{"amount":{"amount":"1300000000000","denom":"uatom"},"delegator_address":"cosmos176m2p8l3fps3dal7h8gf9jvrv98tu3rqfdht86","validator_address":"cosmosvaloper1qs8tnw2t8l6amtzvdemnnsq9dzk0ag0z52uzay"}}],"sequence":"37"} +{"account_number":"24263","chain_id":"cosmoshub-3","fee":{"amount":[{"amount":"100000","denom":"uatom"}],"gas":"333000"},"memo":" ","msgs":[{"type":"cosmos-sdk/MsgUndelegate","value":{"amount":{"amount":"1300000000000","denom":"uatom"},"delegator_address":"cosmos176m2p8l3fps3dal7h8gf9jvrv98tu3rqfdht86","validator_address":"cosmosvaloper13sduv92y3xdhy3rpmhakrc3v7t37e7ps9l0kpv"}}],"sequence":"38"} diff --git a/x/auth/client/utils/tx.go b/x/auth/client/utils/tx.go index 0a5646eb7b..c409dbbffb 100644 --- a/x/auth/client/utils/tx.go +++ b/x/auth/client/utils/tx.go @@ -6,6 +6,7 @@ import ( "fmt" "io/ioutil" "os" + "strings" "github.com/pkg/errors" "github.com/spf13/viper" @@ -248,6 +249,37 @@ func ReadStdTxFromFile(cdc *codec.Codec, filename string) (stdTx authtypes.StdTx return } +// ReadStdTxsFromFile reads a list of transactions from a file +func ReadStdTxsFromFile(cdc *codec.Codec, filename string) ([]authtypes.StdSignMsg, error) { + bz, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + lines := strings.Split(string(bz), "\n") + + return buildTxsFromJsonLines(cdc, lines) +} + +func buildTxsFromJsonLines(cdc *codec.Codec, jsonTxs []string) ([]authtypes.StdSignMsg, error) { + var txs []authtypes.StdSignMsg + + for _, jsonTx := range jsonTxs { + if len(jsonTx) == 0 { + break + } + + var tx authtypes.StdSignMsg + err := cdc.UnmarshalJSON([]byte(jsonTx), &tx) + if err != nil { + return nil, err + } + txs = append(txs, tx) + } + + return txs, nil +} + func populateAccountFromState( txBldr authtypes.TxBuilder, cliCtx context.CLIContext, addr sdk.AccAddress, ) (authtypes.TxBuilder, error) { diff --git a/x/auth/client/utils/tx_test.go b/x/auth/client/utils/tx_test.go index 3ae79f7bdc..2ac0920af0 100644 --- a/x/auth/client/utils/tx_test.go +++ b/x/auth/client/utils/tx_test.go @@ -3,11 +3,16 @@ package utils import ( "encoding/json" "errors" + "fmt" "io/ioutil" "os" "testing" + "github.com/cosmos/cosmos-sdk/x/staking/types" + + "github.com/cosmos/cosmos-sdk/tests" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/tendermint/tendermint/crypto/ed25519" @@ -101,16 +106,32 @@ func TestReadStdTxFromFile(t *testing.T) { stdTx := authtypes.NewStdTx([]sdk.Msg{}, fee, []authtypes.StdSignature{}, "foomemo") // Write it to the file - encodedTx, _ := cdc.MarshalJSON(stdTx) - jsonTxFile := writeToNewTempFile(t, string(encodedTx)) - defer os.Remove(jsonTxFile.Name()) + dir, cleanup := tests.NewTestCaseDir(t) + t.Cleanup(cleanup) + tempFile := newTempFile(t, dir) + + encodedTx, err := cdc.MarshalJSON(stdTx) + require.NoError(t, err) + writeToFile(t, tempFile, string(encodedTx)) // Read it back - decodedTx, err := ReadStdTxFromFile(cdc, jsonTxFile.Name()) + decodedTx, err := ReadStdTxFromFile(cdc, tempFile.Name()) require.NoError(t, err) require.Equal(t, decodedTx.Memo, "foomemo") } +func TestReadStdTxsFromFile(t *testing.T) { + cdc := codec.New() + sdk.RegisterCodec(cdc) + types.RegisterCodec(cdc) + + txsFromFile, err := ReadStdTxsFromFile(cdc, "./testdata/txs") + require.NoError(t, err) + + require.Equal(t, uint64(37), txsFromFile[0].Sequence) + require.Equal(t, uint64(38), txsFromFile[1].Sequence) +} + 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{}, "") @@ -122,16 +143,18 @@ func compareEncoders(t *testing.T, expected sdk.TxEncoder, actual sdk.TxEncoder) require.Equal(t, defaultEncoderBytes, encoderBytes) } -func writeToNewTempFile(t *testing.T, data string) *os.File { - fp, err := ioutil.TempFile(os.TempDir(), "client_tx_test") - require.NoError(t, err) - - _, err = fp.WriteString(data) +func newTempFile(t *testing.T, filepath string) *os.File { + fp, err := ioutil.TempFile(filepath, "client_tx_test") require.NoError(t, err) return fp } +func writeToFile(t *testing.T, fp *os.File, data string) { + _, err := fp.WriteString(fmt.Sprintf("%s\n", data)) + require.NoError(t, err) +} + func makeCodec() *codec.Codec { var cdc = codec.New() sdk.RegisterCodec(cdc) diff --git a/x/auth/types/stdtx.go b/x/auth/types/stdtx.go index 9e71b1127d..a732418408 100644 --- a/x/auth/types/stdtx.go +++ b/x/auth/types/stdtx.go @@ -170,12 +170,12 @@ type StdSignDoc struct { func StdSignBytes(chainID string, accnum uint64, sequence uint64, fee StdFee, msgs []sdk.Msg, memo string) []byte { var msgsBytes []json.RawMessage for _, msg := range msgs { - msgsBytes = append(msgsBytes, json.RawMessage(msg.GetSignBytes())) + msgsBytes = append(msgsBytes, msg.GetSignBytes()) } bz, err := ModuleCdc.MarshalJSON(StdSignDoc{ AccountNumber: accnum, ChainID: chainID, - Fee: json.RawMessage(fee.Bytes()), + Fee: fee.Bytes(), Memo: memo, Msgs: msgsBytes, Sequence: sequence,