diff --git a/PENDING.md b/PENDING.md index 441d8afd0a9f..dc9dba0ecae5 100644 --- a/PENDING.md +++ b/PENDING.md @@ -47,6 +47,7 @@ FEATURES * [lcd] Endpoints to query staking pool and params * [lcd] \#2110 Add support for `simulate=true` requests query argument to endpoints that send txs to run simulations of transactions * [lcd] \#966 Add support for `generate_only=true` query argument to generate offline unsigned transactions + * [lcd] \#1953 Add /sign endpoint to sign transactions generated with `generate_only=true`. * Gaia CLI (`gaiacli`) * [cli] Cmds to query staking pool and params @@ -57,6 +58,7 @@ FEATURES * [cli] \#2047 The --gas-adjustment flag can be used to adjust the estimate obtained via the simulation triggered by --gas=0. * [cli] \#2110 Add --dry-run flag to perform a simulation of a transaction without broadcasting it. The --gas flag is ignored as gas would be automatically estimated. * [cli] \#966 Add --generate-only flag to build an unsigned transaction and write it to STDOUT. + * [cli] \#1953 New `sign` command to sign transactions generated with the --generate-only flag. * Gaia * [cli] #2170 added ability to show the node's address via `gaiad tendermint show-address` diff --git a/client/input.go b/client/input.go index e7d13f3bfdbe..b10f65ce62d3 100644 --- a/client/input.go +++ b/client/input.go @@ -24,7 +24,7 @@ func BufferStdin() *bufio.Reader { // It enforces the password length func GetPassword(prompt string, buf *bufio.Reader) (pass string, err error) { if inputIsTty() { - pass, err = speakeasy.Ask(prompt) + pass, err = speakeasy.FAsk(os.Stderr, prompt) } else { pass, err = readLineFromBuf(buf) } diff --git a/client/lcd/lcd_test.go b/client/lcd/lcd_test.go index 656362bcd63f..6228abcb3818 100644 --- a/client/lcd/lcd_test.go +++ b/client/lcd/lcd_test.go @@ -318,7 +318,8 @@ func TestCoinSendGenerateOnly(t *testing.T) { addr, seed := CreateAddr(t, "test", password, GetKeyBase(t)) cleanup, _, port := InitializeTestLCD(t, 1, []sdk.AccAddress{addr}) defer cleanup() - // create TX + + // generate TX res, body, _ := doSendWithGas(t, port, seed, name, password, addr, 0, 0, "?generate_only=true") require.Equal(t, http.StatusOK, res.StatusCode, body) var msg auth.StdTx @@ -327,6 +328,37 @@ func TestCoinSendGenerateOnly(t *testing.T) { require.Equal(t, msg.Msgs[0].Type(), "bank") require.Equal(t, msg.Msgs[0].GetSigners(), []sdk.AccAddress{addr}) require.Equal(t, 0, len(msg.Signatures)) + + // sign tx + var signedMsg auth.StdTx + acc := getAccount(t, port, addr) + accnum := acc.GetAccountNumber() + sequence := acc.GetSequence() + + payload := struct { + Tx auth.StdTx `json:"tx"` + LocalAccountName string `json:"name"` + Password string `json:"password"` + ChainID string `json:"chain_id"` + AccountNumber int64 `json:"account_number"` + Sequence int64 `json:"sequence"` + }{ + Tx: msg, + LocalAccountName: name, + Password: password, + ChainID: viper.GetString(client.FlagChainID), + AccountNumber: accnum, + Sequence: sequence, + } + json, err := cdc.MarshalJSON(payload) + require.Nil(t, err) + res, body = Request(t, port, "POST", "/sign", json) + require.Equal(t, http.StatusOK, res.StatusCode, body) + require.Nil(t, cdc.UnmarshalJSON([]byte(body), &signedMsg)) + require.Equal(t, len(msg.Msgs), len(signedMsg.Msgs)) + require.Equal(t, msg.Msgs[0].Type(), signedMsg.Msgs[0].Type()) + require.Equal(t, msg.Msgs[0].GetSigners(), signedMsg.Msgs[0].GetSigners()) + require.Equal(t, 1, len(signedMsg.Signatures)) } func TestTxs(t *testing.T) { diff --git a/cmd/gaia/cli_test/cli_test.go b/cmd/gaia/cli_test/cli_test.go index 8b66ab4a1db0..e67ac3082290 100644 --- a/cmd/gaia/cli_test/cli_test.go +++ b/cmd/gaia/cli_test/cli_test.go @@ -5,6 +5,7 @@ package clitest import ( "encoding/json" "fmt" + "io/ioutil" "os" "testing" @@ -332,7 +333,7 @@ func TestGaiaCLISubmitProposal(t *testing.T) { require.Equal(t, " 2 - Apples", proposalsQuery) } -func TestGaiaCLISendGenerateOnly(t *testing.T) { +func TestGaiaCLISendGenerateAndSign(t *testing.T) { chainID, servAddr, port := initializeFixtures(t) flags := fmt.Sprintf("--home=%s --node=%v --chain-id=%v", gaiacliHome, servAddr, chainID) @@ -343,6 +344,7 @@ func TestGaiaCLISendGenerateOnly(t *testing.T) { tests.WaitForTMStart(port) tests.WaitForNextNBlocksTM(2, port) + fooAddr, _ := executeGetAddrPK(t, fmt.Sprintf("gaiacli keys show foo --output=json --home=%s", gaiacliHome)) barAddr, _ := executeGetAddrPK(t, fmt.Sprintf("gaiacli keys show bar --output=json --home=%s", gaiacliHome)) // Test generate sendTx with default gas @@ -376,6 +378,35 @@ func TestGaiaCLISendGenerateOnly(t *testing.T) { require.Equal(t, msg.Fee.Gas, int64(100)) require.Equal(t, len(msg.Msgs), 1) require.Equal(t, 0, len(msg.GetSignatures())) + + // Write the output to disk + unsignedTxFile := writeToNewTempFile(t, stdout) + defer os.Remove(unsignedTxFile.Name()) + + // Test sign --print-sigs + success, stdout, _ = executeWriteRetStdStreams(t, fmt.Sprintf( + "gaiacli sign %v --print-sigs %v", flags, unsignedTxFile.Name())) + require.True(t, success) + require.Equal(t, fmt.Sprintf("Signers:\n 0: %v\n\nSignatures:\n", fooAddr.String()), stdout) + + // Test sign + success, stdout, _ = executeWriteRetStdStreams(t, fmt.Sprintf( + "gaiacli sign %v --name=foo %v", flags, unsignedTxFile.Name()), app.DefaultKeyPass) + require.True(t, success) + msg = unmarshalStdTx(t, stdout) + require.Equal(t, len(msg.Msgs), 1) + require.Equal(t, 1, len(msg.GetSignatures())) + require.Equal(t, fooAddr.String(), msg.GetSigners()[0].String()) + + // Write the output to disk + signedTxFile := writeToNewTempFile(t, stdout) + defer os.Remove(signedTxFile.Name()) + + // Test sign --print-signatures + success, stdout, _ = executeWriteRetStdStreams(t, fmt.Sprintf( + "gaiacli sign %v --print-sigs %v", flags, signedTxFile.Name())) + require.True(t, success) + require.Equal(t, fmt.Sprintf("Signers:\n 0: %v\n\nSignatures:\n 0: %v\n", fooAddr.String(), fooAddr.String()), stdout) } //___________________________________________________________________________________ @@ -408,6 +439,15 @@ func unmarshalStdTx(t *testing.T, s string) (stdTx auth.StdTx) { return } +func writeToNewTempFile(t *testing.T, s string) *os.File { + fp, err := ioutil.TempFile(os.TempDir(), "cosmos_cli_test_") + require.Nil(t, err) + // defer os.Remove(signedTxFile.Name()) + _, err = fp.WriteString(s) + require.Nil(t, err) + return fp +} + //___________________________________________________________________________________ // executors diff --git a/cmd/gaia/cmd/gaiacli/main.go b/cmd/gaia/cmd/gaiacli/main.go index df0fd3c1138e..7a1af622145d 100644 --- a/cmd/gaia/cmd/gaiacli/main.go +++ b/cmd/gaia/cmd/gaiacli/main.go @@ -127,6 +127,7 @@ func main() { rootCmd.AddCommand( client.GetCommands( authcmd.GetAccountCmd("acc", cdc, authcmd.GetAccountDecoder(cdc)), + authcmd.GetSignCommand(cdc, authcmd.GetAccountDecoder(cdc)), )...) rootCmd.AddCommand( client.PostCommands( diff --git a/docs/sdk/clients.md b/docs/sdk/clients.md index fdfbca7bd2fe..0cc87cf21500 100644 --- a/docs/sdk/clients.md +++ b/docs/sdk/clients.md @@ -147,7 +147,16 @@ gaiacli send \ --chain-id= \ --name= \ --to= \ - --generate-only + --generate-only > unsignedSendTx.json +``` + +You can now sign the transaction file generated through the `--generate-only` flag by providing your key to the following command: + +```bash +gaiacli sign \ + --chain-id= \ + --name= + unsignedSendTx.json > signedSendTx.json ``` ### Staking diff --git a/x/auth/client/cli/sign.go b/x/auth/client/cli/sign.go new file mode 100644 index 000000000000..b1948126cd59 --- /dev/null +++ b/x/auth/client/cli/sign.go @@ -0,0 +1,124 @@ +package cli + +import ( + "fmt" + "io/ioutil" + + "github.com/spf13/viper" + + "github.com/cosmos/cosmos-sdk/client" + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/keys" + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/cosmos/cosmos-sdk/x/auth" + authctx "github.com/cosmos/cosmos-sdk/x/auth/client/context" + "github.com/spf13/cobra" + amino "github.com/tendermint/go-amino" +) + +const ( + flagOverwriteSigs = "overwrite" + flagPrintSigs = "print-sigs" +) + +// GetSignCommand returns the sign command +func GetSignCommand(codec *amino.Codec, decoder auth.AccountDecoder) *cobra.Command { + cmd := &cobra.Command{ + Use: "sign ", + Short: "Sign transactions", + Long: `Sign transactions created with the --generate-only flag. +Read a transaction from , sign it, and print its JSON encoding.`, + RunE: makeSignCmd(codec, decoder), + Args: cobra.ExactArgs(1), + } + cmd.Flags().String(client.FlagName, "", "Name of private key with which to sign") + cmd.Flags().Bool(flagOverwriteSigs, false, "Overwrite the signatures that are already attached to the transaction") + cmd.Flags().Bool(flagPrintSigs, false, "Print the addresses that must sign the transaction and those who have already signed it, then exit") + return cmd +} + +func makeSignCmd(cdc *amino.Codec, decoder auth.AccountDecoder) func(cmd *cobra.Command, args []string) error { + return func(cmd *cobra.Command, args []string) (err error) { + stdTx, err := readAndUnmarshalStdTx(cdc, args[0]) + if err != nil { + return + } + + if viper.GetBool(flagPrintSigs) { + printSignatures(stdTx) + return nil + } + + name := viper.GetString(client.FlagName) + keybase, err := keys.GetKeyBase() + if err != nil { + return + } + info, err := keybase.Get(name) + if err != nil { + return + } + + cliCtx := context.NewCLIContext().WithCodec(cdc).WithAccountDecoder(decoder) + acc, err := cliCtx.GetAccount(sdk.AccAddress(info.GetPubKey().Address())) + if err != nil { + return err + } + + passphrase, err := keys.GetPassphrase(name) + if err != nil { + return err + } + newTx, err := signStdTx(stdTx, name, passphrase, acc) + if err != nil { + return err + } + json, err := cdc.MarshalJSON(newTx) + if err != nil { + return err + } + fmt.Printf("%s\n", json) + return + } +} + +func signStdTx(stdTx auth.StdTx, name, passphrase string, acc auth.Account) (signedStdTx auth.StdTx, err error) { + stdSignature, err := authctx.MakeSignature(name, passphrase, auth.StdSignMsg{ + ChainID: viper.GetString(client.FlagChainID), + AccountNumber: acc.GetAccountNumber(), + Sequence: acc.GetSequence(), + Fee: stdTx.Fee, + Msgs: stdTx.GetMsgs(), + Memo: stdTx.GetMemo(), + }) + if err != nil { + return + } + + signedStdTx = authctx.SignStdTx(stdTx, stdSignature, viper.GetBool(flagOverwriteSigs)) + return +} + +func printSignatures(stdTx auth.StdTx) { + fmt.Println("Signers:") + for i, signer := range stdTx.GetSigners() { + fmt.Printf(" %v: %v\n", i, signer.String()) + } + fmt.Println("") + fmt.Println("Signatures:") + for i, sig := range stdTx.GetSignatures() { + fmt.Printf(" %v: %v\n", i, sdk.AccAddress(sig.Address()).String()) + } + return +} + +func readAndUnmarshalStdTx(cdc *amino.Codec, filename string) (stdTx auth.StdTx, err error) { + var bytes []byte + if bytes, err = ioutil.ReadFile(filename); err != nil { + return + } + if err = cdc.UnmarshalJSON(bytes, &stdTx); err != nil { + return + } + return +} diff --git a/x/auth/client/context/context.go b/x/auth/client/context/context.go index 5e55696b83e5..3d10f712b9e1 100644 --- a/x/auth/client/context/context.go +++ b/x/auth/client/context/context.go @@ -117,24 +117,11 @@ func (ctx TxContext) Build(msgs []sdk.Msg) (auth.StdSignMsg, error) { // Sign signs a transaction given a name, passphrase, and a single message to // signed. An error is returned if signing fails. func (ctx TxContext) Sign(name, passphrase string, msg auth.StdSignMsg) ([]byte, error) { - keybase, err := keys.GetKeyBase() + sig, err := MakeSignature(name, passphrase, msg) if err != nil { return nil, err } - - sig, pubkey, err := keybase.Sign(name, passphrase, msg.Bytes()) - if err != nil { - return nil, err - } - - sigs := []auth.StdSignature{{ - AccountNumber: msg.AccountNumber, - Sequence: msg.Sequence, - PubKey: pubkey, - Signature: sig, - }} - - return ctx.Codec.MarshalBinary(auth.NewStdTx(msg.Msgs, msg.Fee, sigs, msg.Memo)) + return ctx.Codec.MarshalBinary(auth.NewStdTx(msg.Msgs, msg.Fee, []auth.StdSignature{sig}, msg.Memo)) } // BuildAndSign builds a single message to be signed, and signs a transaction @@ -177,3 +164,33 @@ func (ctx TxContext) BuildWithPubKey(name string, msgs []sdk.Msg) ([]byte, error return ctx.Codec.MarshalBinary(auth.NewStdTx(msg.Msgs, msg.Fee, sigs, msg.Memo)) } + +// MakeSignature builds a StdSignature given key name, passphrase, and a StdSignMsg. +func MakeSignature(name, passphrase string, msg auth.StdSignMsg) (sig auth.StdSignature, err error) { + keybase, err := keys.GetKeyBase() + if err != nil { + return + } + sigBytes, pubkey, err := keybase.Sign(name, passphrase, msg.Bytes()) + if err != nil { + return + } + return auth.StdSignature{ + AccountNumber: msg.AccountNumber, + Sequence: msg.Sequence, + PubKey: pubkey, + Signature: sigBytes, + }, nil +} + +// SignStdTx attach a signature to a StdTx and returns a copy of a it. If overwriteSigs is true, +// it replaces the signatures already attached if there's any with the given signature. +func SignStdTx(stdTx auth.StdTx, stdSignature auth.StdSignature, overwriteSigs bool) auth.StdTx { + sigs := stdTx.GetSignatures() + if len(sigs) == 0 || overwriteSigs { + sigs = []auth.StdSignature{stdSignature} + } else { + sigs = append(sigs, stdSignature) + } + return auth.NewStdTx(stdTx.GetMsgs(), stdTx.Fee, sigs, stdTx.GetMemo()) +} diff --git a/x/auth/client/rest/query.go b/x/auth/client/rest/query.go index 6ad50a14dda4..3a5ab756d16e 100644 --- a/x/auth/client/rest/query.go +++ b/x/auth/client/rest/query.go @@ -20,6 +20,10 @@ func RegisterRoutes(cliCtx context.CLIContext, r *mux.Router, cdc *wire.Codec, s "/accounts/{address}", QueryAccountRequestHandlerFn(storeName, cdc, authcmd.GetAccountDecoder(cdc), cliCtx), ).Methods("GET") + r.HandleFunc( + "/sign", + SignTxRequestHandlerFn(cdc, cliCtx), + ).Methods("POST") } // query accountREST Handler diff --git a/x/auth/client/rest/sign.go b/x/auth/client/rest/sign.go new file mode 100644 index 000000000000..e92362ec6a8f --- /dev/null +++ b/x/auth/client/rest/sign.go @@ -0,0 +1,58 @@ +package rest + +import ( + "io/ioutil" + "net/http" + + "github.com/cosmos/cosmos-sdk/client/context" + "github.com/cosmos/cosmos-sdk/client/utils" + "github.com/cosmos/cosmos-sdk/wire" + "github.com/cosmos/cosmos-sdk/x/auth" + authctx "github.com/cosmos/cosmos-sdk/x/auth/client/context" +) + +type signBody struct { + Tx auth.StdTx `json:"tx"` + LocalAccountName string `json:"name"` + Password string `json:"password"` + ChainID string `json:"chain_id"` + AccountNumber int64 `json:"account_number"` + Sequence int64 `json:"sequence"` +} + +// sign tx REST handler +func SignTxRequestHandlerFn(cdc *wire.Codec, cliCtx context.CLIContext) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var m signBody + body, err := ioutil.ReadAll(r.Body) + if err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + err = cdc.UnmarshalJSON(body, &m) + if err != nil { + utils.WriteErrorResponse(w, http.StatusBadRequest, err.Error()) + return + } + + sig, err := authctx.MakeSignature(m.LocalAccountName, m.Password, auth.StdSignMsg{ + ChainID: m.ChainID, + AccountNumber: m.AccountNumber, + Sequence: m.Sequence, + Fee: m.Tx.Fee, + Msgs: m.Tx.GetMsgs(), + Memo: m.Tx.GetMemo(), + }) + if err != nil { + utils.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + + output, err := wire.MarshalJSONIndent(cdc, authctx.SignStdTx(m.Tx, sig, false)) + if err != nil { + utils.WriteErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + w.Write(output) + } +}