Repository: vegaprotocol/vegawallet (go)

Issue:
Implement a new subcommand to sign a KeyRotate transaction
The validator will need to sign a transaction to be send to the network when they want their key to be rotated

This will need to be a special subcommand, which instead of sending the transaction straight to the network (we expect the master key to be held on a computer without access to internet), will just output a raw transaction.

Once the transaction is signed, it can be used with a wallet connected to the internet, to be sent to the vega network.

### Tasks
- [ ] Add a new subcommand to sign the KeyRotateCommand payload with a new validator key
- [ ] This subcommand should just output the transaction in protobuf bytes, encoded in base64

Pull request description:
Add key rotate command
Close #246 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3226c67..2b4ee02 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -21,7 +21,7 @@
 - [361](https://github.com/vegaprotocol/vegawallet/pull/361) - Add `network describe` subcommand to allow querying of network information
 - [366](https://github.com/vegaprotocol/vegawallet/pull/366) - Update the changelog checker action to run when required only
 - [368](https://github.com/vegaprotocol/vegawallet/pull/368) - Add `send tx` subcommand to send raw transaction
-
+- [367](https://github.com/vegaprotocol/vegawallet/pull/367) - Add `key rotate` subcommand to generate key rotation transaction payload
 
 ### Fixes
 - [356](https://github.com/vegaprotocol/vegawallet/pull/356) - Ensure the interactive printer is listening to color management env vars (`NO_COLOR` and `CLICOLOR`)
diff --git a/cmd/flags/errors.go b/cmd/flags/errors.go
index 5a22663..da3d5d2 100644
--- a/cmd/flags/errors.go
+++ b/cmd/flags/errors.go
@@ -25,6 +25,12 @@ func FlagMustBeSpecifiedError(name string) error {
 	}
 }
 
+func FlagRequireLessThanFlagError(less, greater string) error {
+	return FlagError{
+		message: fmt.Sprintf("--%s flag must be greater than --%s", greater, less),
+	}
+}
+
 func ArgMustBeSpecifiedError(name string) error {
 	return FlagError{
 		message: fmt.Sprintf("%s argument must be specified", name),
diff --git a/cmd/key.go b/cmd/key.go
index 8535613..76ba5e3 100644
--- a/cmd/key.go
+++ b/cmd/key.go
@@ -24,6 +24,7 @@ func NewCmdKey(w io.Writer, rf *RootFlags) *cobra.Command {
 	cmd.AddCommand(NewCmdDescribeKey(w, rf))
 	cmd.AddCommand(NewCmdTaintKey(w, rf))
 	cmd.AddCommand(NewCmdUntaintKey(w, rf))
+	cmd.AddCommand(NewCmdRotateKey(w, rf))
 	return cmd
 }
 
diff --git a/cmd/key_rotate.go b/cmd/key_rotate.go
new file mode 100644
index 0000000..6106d74
--- /dev/null
+++ b/cmd/key_rotate.go
@@ -0,0 +1,154 @@
+package cmd
+
+import (
+	"fmt"
+	"io"
+
+	"code.vegaprotocol.io/vegawallet/cmd/cli"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"code.vegaprotocol.io/vegawallet/cmd/printer"
+	"code.vegaprotocol.io/vegawallet/wallet"
+	"code.vegaprotocol.io/vegawallet/wallets"
+	"github.com/spf13/cobra"
+)
+
+var (
+	rotateKeyLong = cli.LongDesc(`
+		Build a signed key rotation transaction as a Base64 encoded string.
+		Choose a public key to rotate to and target block height.
+
+		Later the transaction is applied to Vega protocol through the wallet's "send tx" command.
+	`)
+
+	rotateKeyExample = cli.Examples(`
+		# Build signed transaction for rotating to new key public key
+		vegawallet key rotate --wallet WALLET --tx-height TX_HEIGHT --target-height TARGET_HEIGHT --pubkey PUBLIC_KEY
+	`)
+)
+
+type RotateKeyHandler func(*wallet.RotateKeyRequest) (*wallet.RotateKeyResponse, error)
+
+func NewCmdRotateKey(w io.Writer, rf *RootFlags) *cobra.Command {
+	h := func(req *wallet.RotateKeyRequest) (*wallet.RotateKeyResponse, error) {
+		s, err := wallets.InitialiseStore(rf.Home)
+		if err != nil {
+			return nil, fmt.Errorf("couldn't initialise wallets store: %w", err)
+		}
+
+		return wallet.RotateKey(s, req)
+	}
+
+	return BuildCmdRotateKey(w, h, rf)
+}
+
+func BuildCmdRotateKey(w io.Writer, handler RotateKeyHandler, rf *RootFlags) *cobra.Command {
+	f := RotateKeyFlags{}
+
+	cmd := &cobra.Command{
+		Use:     "rotate",
+		Short:   "Build a signed key rotation transaction",
+		Long:    rotateKeyLong,
+		Example: rotateKeyExample,
+		RunE: func(_ *cobra.Command, args []string) error {
+			req, err := f.Validate()
+			if err != nil {
+				return err
+			}
+
+			resp, err := handler(req)
+			if err != nil {
+				return err
+			}
+
+			switch rf.Output {
+			case flags.InteractiveOutput:
+				PrintRotateKeyResponse(w, resp)
+			case flags.JSONOutput:
+				return printer.FprintJSON(w, resp)
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVarP(&f.Wallet,
+		"wallet", "w",
+		"",
+		"Wallet holding the master key and new public key",
+	)
+	cmd.Flags().StringVarP(&f.PassphraseFile,
+		"passphrase-file", "p",
+		"",
+		"Path to the file containing the wallet's passphrase",
+	)
+	cmd.Flags().StringVarP(&f.PublicKey,
+		"pubkey", "k",
+		"",
+		"A public key to rotate to. Should be generated by wallet's 'generate' command.",
+	)
+	cmd.Flags().Uint64VarP(&f.TxBlockHeight,
+		"tx-height", "x",
+		0,
+		"It should be close to the current block height when the transaction is applied, with a threshold of ~ - 150 blocks.",
+	)
+	cmd.Flags().Uint64VarP(&f.TargetBlockHeight,
+		"target-height", "t",
+		0,
+		"Height of block where the public key change will take effect",
+	)
+
+	return cmd
+}
+
+type RotateKeyFlags struct {
+	Wallet            string
+	PassphraseFile    string
+	PublicKey         string
+	TxBlockHeight     uint64
+	TargetBlockHeight uint64
+}
+
+func (f *RotateKeyFlags) Validate() (*wallet.RotateKeyRequest, error) {
+	req := &wallet.RotateKeyRequest{}
+
+	if f.PublicKey == "" {
+		return nil, flags.FlagMustBeSpecifiedError("pubkey")
+	}
+	req.PublicKey = f.PublicKey
+
+	if f.TargetBlockHeight == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("target-height")
+	}
+	req.TargetBlockHeight = f.TargetBlockHeight
+
+	if f.TxBlockHeight == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("tx-height")
+	}
+	req.TxBlockHeight = f.TxBlockHeight
+
+	if req.TargetBlockHeight <= req.TxBlockHeight {
+		return nil, flags.FlagRequireLessThanFlagError("tx-height", "target-height")
+	}
+
+	if len(f.Wallet) == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("wallet")
+	}
+	req.Wallet = f.Wallet
+
+	passphrase, err := flags.GetPassphrase(f.PassphraseFile)
+	if err != nil {
+		return nil, err
+	}
+	req.Passphrase = passphrase
+
+	return req, nil
+}
+
+func PrintRotateKeyResponse(w io.Writer, req *wallet.RotateKeyResponse) {
+	p := printer.NewInteractivePrinter(w)
+	p.CheckMark().SuccessText("Key rotation succeeded").NextSection()
+	p.Text("Base64 encoded transaction:").NextLine()
+	p.Text(req.Base64Transaction).NextLine()
+	p.Text("Master public key used:").NextLine()
+	p.Text(req.MasterPublicKey).NextLine()
+}
diff --git a/go.mod b/go.mod
index 3073fc1..5b950a7 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@ module code.vegaprotocol.io/vegawallet
 go 1.17
 
 require (
-	code.vegaprotocol.io/protos v0.45.2-0.20211026143814-fd1dc62fd4e8
+	code.vegaprotocol.io/protos v0.45.2-0.20211116120935-7f71d8b3c70e
 	code.vegaprotocol.io/shared v0.0.0-20211015074835-9ed837d93090
 	github.com/blang/semver/v4 v4.0.0
 	github.com/cenkalti/backoff/v4 v4.0.2
diff --git a/go.sum b/go.sum
index 49685c2..a453951 100644
--- a/go.sum
+++ b/go.sum
@@ -10,8 +10,8 @@ cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7
 cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqClKRT5SZwBmk=
 cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
 cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
-code.vegaprotocol.io/protos v0.45.2-0.20211026143814-fd1dc62fd4e8 h1:+TW9DW+OBW/IEHEYk2Q7F8t7VSXQ5Y8qLdS+iLjOzfY=
-code.vegaprotocol.io/protos v0.45.2-0.20211026143814-fd1dc62fd4e8/go.mod h1:NORK22Evpwn0GoetL7H6/oVUzGcfjAJEJTzw+zTkSyE=
+code.vegaprotocol.io/protos v0.45.2-0.20211116120935-7f71d8b3c70e h1:CVfa4wmUY2touFMHa2Ti29ZKsPif64EDvH6nljC/LAk=
+code.vegaprotocol.io/protos v0.45.2-0.20211116120935-7f71d8b3c70e/go.mod h1:NORK22Evpwn0GoetL7H6/oVUzGcfjAJEJTzw+zTkSyE=
 code.vegaprotocol.io/shared v0.0.0-20211015074835-9ed837d93090 h1:SF7ZRvZ/JASJNEQigbuUwk1rQuugLy6gqbOFs8D8VXI=
 code.vegaprotocol.io/shared v0.0.0-20211015074835-9ed837d93090/go.mod h1:dmahiiphuDJvjXXDvQvo22tFPx7yk9wW8z1glU/JgHE=
 dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
@@ -185,7 +185,6 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
 github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -272,7 +271,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxv
 github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/libp2p/go-buffer-pool v0.0.2/go.mod h1:MvaB6xw5vOrDl8rYZGLFdKAuk/hRoRZd1Vi32+RXyFM=
 github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
@@ -319,7 +317,6 @@ github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzE
 github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
 github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
 github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
-github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
 github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
 github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
 github.com/oasisprotocol/curve25519-voi v0.0.0-20210716083614-f38f8e8b0b84 h1:VGE87qI0pfuQ23PLPiXpXIdk4gVGFZpq5H5A2f1dZcY=
@@ -355,7 +352,6 @@ github.com/pierrec/lz4 v1.0.2-0.20190131084431-473cd7ce01a1/go.mod h1:3/3N9NVKO0
 github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
 github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
-github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/profile v1.2.1/go.mod h1:hJw3o1OdXxsrSjjVksARp5W95eeEaEfptyVZyv6JUPA=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -746,7 +742,6 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U=
 gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/cheggaaa/pb.v1 v1.0.25/go.mod h1:V/YB90LKu/1FcN3WVnfiiE5oMCibMjukxqG/qStrOgw=
 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
diff --git a/wallet/errors.go b/wallet/errors.go
index b93fa7e..04f489e 100644
--- a/wallet/errors.go
+++ b/wallet/errors.go
@@ -7,6 +7,8 @@ import (
 
 var (
 	ErrIsolatedWalletCantGenerateKeyPairs = errors.New("isolated wallet can't generate key pairs")
+	ErrIsolatedWalletDoesNotHaveMasterKey = errors.New("isolated wallet doesn't have a master key")
+	ErrCantRotateKeyInIsolatedWallet      = errors.New("isolated wallet can't rotate key")
 	ErrInvalidMnemonic                    = errors.New("mnemonic is not valid")
 	ErrPubKeyAlreadyTainted               = errors.New("public key is already tainted")
 	ErrPubKeyIsTainted                    = errors.New("public key is tainted")
diff --git a/wallet/handler.go b/wallet/handler.go
index 8009b1e..6647ef9 100644
--- a/wallet/handler.go
+++ b/wallet/handler.go
@@ -4,6 +4,10 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+
+	"code.vegaprotocol.io/protos/commands"
+	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	"github.com/golang/protobuf/proto"
 )
 
 //go:generate go run github.com/golang/mock/mockgen -destination mocks/store_mock.go -package mocks code.vegaprotocol.io/vegawallet/wallet Store
@@ -241,12 +245,13 @@ func ListKeys(store Store, req *ListKeysRequest) (*ListKeysResponse, error) {
 }
 
 func DescribeKey(store Store, req *DescribeKeyRequest) (*DescribeKeyResponse, error) {
-	resp := &DescribeKeyResponse{}
 	w, err := getWallet(store, req.Wallet, req.Passphrase)
 	if err != nil {
 		return nil, err
 	}
 
+	resp := &DescribeKeyResponse{}
+
 	kp, err := w.DescribeKeyPair(req.PubKey)
 	if err != nil {
 		return nil, err
@@ -259,6 +264,80 @@ func DescribeKey(store Store, req *DescribeKeyRequest) (*DescribeKeyResponse, er
 	return resp, nil
 }
 
+type RotateKeyRequest struct {
+	Wallet            string
+	Passphrase        string
+	PublicKey         string
+	TxBlockHeight     uint64
+	TargetBlockHeight uint64
+}
+
+type RotateKeyResponse struct {
+	MasterPublicKey   string `json:"masterPublicKey"`
+	Base64Transaction string `json:"base64Transaction"`
+}
+
+func RotateKey(store Store, req *RotateKeyRequest) (*RotateKeyResponse, error) {
+	w, err := getWallet(store, req.Wallet, req.Passphrase)
+	if err != nil {
+		return nil, err
+	}
+
+	mKeyPair, err := w.GetMasterKeyPair()
+	if errors.Is(err, ErrIsolatedWalletDoesNotHaveMasterKey) {
+		return nil, ErrCantRotateKeyInIsolatedWallet
+	}
+	if err != nil {
+		return nil, err
+	}
+
+	pubKey, err := w.DescribePublicKey(req.PublicKey)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't get the public key: %w", err)
+	}
+
+	if pubKey.IsTainted() {
+		return nil, ErrPubKeyIsTainted
+	}
+
+	inputData := commands.NewInputData(req.TxBlockHeight)
+	inputData.Command = &commandspb.InputData_KeyRotateSubmission{
+		KeyRotateSubmission: &commandspb.KeyRotateSubmission{
+			KeyNumber:     pubKey.Index(),
+			TargetBlock:   req.TargetBlockHeight,
+			Time:          0, // @TODO fill this
+			NewPubKeyHash: pubKey.Hash(),
+		},
+	}
+
+	data, err := proto.Marshal(inputData)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't marshal key rotate submission input data: %w", err)
+	}
+
+	sign, err := mKeyPair.Sign(data)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't sign key rotate submission input data: %w", err)
+	}
+
+	protoSignature := &commandspb.Signature{
+		Value:   sign.Value,
+		Algo:    sign.Algo,
+		Version: sign.Version,
+	}
+
+	transaction := commands.NewTransaction(mKeyPair.PublicKey(), data, protoSignature)
+	transactionRaw, err := proto.Marshal(transaction)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't marshal transaction: %w", err)
+	}
+
+	return &RotateKeyResponse{
+		MasterPublicKey:   mKeyPair.PublicKey(),
+		Base64Transaction: base64.StdEncoding.EncodeToString(transactionRaw),
+	}, nil
+}
+
 type GetWalletInfoRequest struct {
 	Wallet     string
 	Passphrase string
diff --git a/wallet/hd_master_key_pair.go b/wallet/hd_master_key_pair.go
new file mode 100644
index 0000000..5be5836
--- /dev/null
+++ b/wallet/hd_master_key_pair.go
@@ -0,0 +1,69 @@
+package wallet
+
+import (
+	"crypto/ed25519"
+	"encoding/hex"
+
+	"code.vegaprotocol.io/vegawallet/crypto"
+)
+
+type HDMasterKeyPair struct {
+	publicKey  *key
+	privateKey *key
+	algo       crypto.SignatureAlgorithm
+}
+
+func NewHDMasterKeyPair(
+	publicKey ed25519.PublicKey,
+	privateKey ed25519.PrivateKey,
+) (*HDMasterKeyPair, error) {
+	algo, err := crypto.NewSignatureAlgorithm(crypto.Ed25519, 1)
+	if err != nil {
+		return nil, err
+	}
+
+	return &HDMasterKeyPair{
+		publicKey: &key{
+			bytes:   publicKey,
+			encoded: hex.EncodeToString(publicKey),
+		},
+		privateKey: &key{
+			bytes:   privateKey,
+			encoded: hex.EncodeToString(privateKey),
+		},
+		algo: algo,
+	}, nil
+}
+
+func (k *HDMasterKeyPair) PublicKey() string {
+	return k.publicKey.encoded
+}
+
+func (k *HDMasterKeyPair) PrivateKey() string {
+	return k.privateKey.encoded
+}
+
+func (k *HDMasterKeyPair) AlgorithmVersion() uint32 {
+	return k.algo.Version()
+}
+
+func (k *HDMasterKeyPair) AlgorithmName() string {
+	return k.algo.Name()
+}
+
+func (k *HDMasterKeyPair) SignAny(data []byte) ([]byte, error) {
+	return k.algo.Sign(k.privateKey.bytes, data)
+}
+
+func (k *HDMasterKeyPair) Sign(data []byte) (*Signature, error) {
+	sig, err := k.algo.Sign(k.privateKey.bytes, data)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Signature{
+		Value:   hex.EncodeToString(sig),
+		Algo:    k.algo.Name(),
+		Version: k.algo.Version(),
+	}, nil
+}
diff --git a/wallet/hd_public_key.go b/wallet/hd_public_key.go
index 24aff0e..6358e5a 100644
--- a/wallet/hd_public_key.go
+++ b/wallet/hd_public_key.go
@@ -1,7 +1,10 @@
 package wallet
 
 import (
+	"encoding/hex"
 	"encoding/json"
+
+	vgcrypto "code.vegaprotocol.io/shared/libs/crypto"
 )
 
 type HDPublicKey struct {
@@ -47,3 +50,7 @@ func (k *HDPublicKey) UnmarshalJSON(data []byte) error {
 	aliasPublicKey := (*alias)(k)
 	return json.Unmarshal(data, aliasPublicKey)
 }
+
+func (k *HDPublicKey) Hash() string {
+	return hex.EncodeToString(vgcrypto.Hash([]byte(k.PublicKey)))
+}
diff --git a/wallet/hd_wallet.go b/wallet/hd_wallet.go
index 8a272f8..ca3a6b4 100644
--- a/wallet/hd_wallet.go
+++ b/wallet/hd_wallet.go
@@ -108,7 +108,22 @@ func (w *HDWallet) DescribeKeyPair(pubKey string) (KeyPair, error) {
 	return &keyPair, nil
 }
 
-// DescribePublicKey returns all the information associated with a public key,
+// GetMasterKeyPair returns all the information associated to a master key pair.
+func (w *HDWallet) GetMasterKeyPair() (MasterKeyPair, error) {
+	if w.IsIsolated() {
+		return nil, ErrIsolatedWalletDoesNotHaveMasterKey
+	}
+
+	pubKey, priKey := w.node.Keypair()
+	keyPair, err := NewHDMasterKeyPair(pubKey, priKey)
+	if err != nil {
+		return nil, fmt.Errorf("failed to get master key pair: %w", err)
+	}
+
+	return keyPair, nil
+}
+
+// DescribePublicKey returns all the information associated to a public key,
 // except the private key.
 func (w *HDWallet) DescribePublicKey(pubKey string) (PublicKey, error) {
 	keyPair, ok := w.keyRing.FindPair(pubKey)
diff --git a/wallet/wallet.go b/wallet/wallet.go
index 5a9e03a..b5ec6b5 100644
--- a/wallet/wallet.go
+++ b/wallet/wallet.go
@@ -10,6 +10,7 @@ type Wallet interface {
 	DescribeKeyPair(pubKey string) (KeyPair, error)
 	ListPublicKeys() []PublicKey
 	ListKeyPairs() []KeyPair
+	GetMasterKeyPair() (MasterKeyPair, error)
 	GenerateKeyPair(meta []Meta) (KeyPair, error)
 	TaintKey(pubKey string) error
 	UntaintKey(pubKey string) error
@@ -25,6 +26,7 @@ type KeyPair interface {
 	PrivateKey() string
 	IsTainted() bool
 	Meta() []Meta
+	Index() uint32
 	AlgorithmVersion() uint32
 	AlgorithmName() string
 	SignAny(data []byte) ([]byte, error)
@@ -36,9 +38,21 @@ type PublicKey interface {
 	Key() string
 	IsTainted() bool
 	Meta() []Meta
+	Index() uint32
 	AlgorithmVersion() uint32
 	AlgorithmName() string
+	Hash() string
 
 	MarshalJSON() ([]byte, error)
 	UnmarshalJSON(data []byte) error
 }
+
+type MasterKeyPair interface {
+	PublicKey() string
+	PrivateKey() string
+	AlgorithmVersion() uint32
+	AlgorithmName() string
+
+	SignAny(data []byte) ([]byte, error)
+	Sign(data []byte) (*Signature, error)
+}


Test patch:
diff --git a/cmd/key_rotate_test.go b/cmd/key_rotate_test.go
new file mode 100644
index 0000000..6cf257c
--- /dev/null
+++ b/cmd/key_rotate_test.go
@@ -0,0 +1,156 @@
+package cmd_test
+
+import (
+	"testing"
+
+	vgrand "code.vegaprotocol.io/shared/libs/rand"
+	"code.vegaprotocol.io/vegawallet/cmd"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"code.vegaprotocol.io/vegawallet/wallet"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRotateKeyFlags(t *testing.T) {
+	t.Run("Valid flags succeeds", testRotateKeyFlagsValidFlagsSucceeds)
+	t.Run("Missing wallet fails", testRotateKeyFlagsMissingWalletFails)
+	t.Run("Missing public key fails", testRotateKeyFlagsMissingPublicKeyFails)
+	t.Run("Missing tx height fails", testRotateKeyFlagsMissingTxBlockHeightFails)
+	t.Run("Missing target height fails", testRotateKeyFlagsMissingTargetBlockHeightFails)
+	t.Run("Validate fails when target height is less then tx height", testRotateKeyFlagsTargetFailsWhenBlockHeightIsLessThanTXHeight)
+}
+
+func testRotateKeyFlagsValidFlagsSucceeds(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	passphrase, passphraseFilePath := NewPassphraseFile(t, testDir)
+	walletName := vgrand.RandomStr(10)
+	pubKey := vgrand.RandomStr(20)
+	txBlockHeight := uint64(20)
+	targetBlockHeight := uint64(25)
+
+	f := &cmd.RotateKeyFlags{
+		Wallet:            walletName,
+		PassphraseFile:    passphraseFilePath,
+		PublicKey:         pubKey,
+		TxBlockHeight:     txBlockHeight,
+		TargetBlockHeight: targetBlockHeight,
+	}
+
+	expectedReq := &wallet.RotateKeyRequest{
+		Wallet:            walletName,
+		Passphrase:        passphrase,
+		PublicKey:         pubKey,
+		TxBlockHeight:     txBlockHeight,
+		TargetBlockHeight: targetBlockHeight,
+	}
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	require.NoError(t, err)
+	require.NotNil(t, req)
+	assert.Equal(t, expectedReq, req)
+}
+
+func testRotateKeyFlagsMissingWalletFails(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	f := newRotateKeyFlags(t, testDir)
+	f.Wallet = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("wallet"))
+	assert.Nil(t, req)
+}
+
+func testRotateKeyFlagsMissingTxBlockHeightFails(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	f := newRotateKeyFlags(t, testDir)
+	f.TxBlockHeight = 0
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("tx-height"))
+	assert.Nil(t, req)
+}
+
+func testRotateKeyFlagsMissingTargetBlockHeightFails(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	f := newRotateKeyFlags(t, testDir)
+	f.TargetBlockHeight = 0
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("target-height"))
+	assert.Nil(t, req)
+}
+
+func testRotateKeyFlagsMissingPublicKeyFails(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	f := newRotateKeyFlags(t, testDir)
+	f.PublicKey = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("pubkey"))
+	assert.Nil(t, req)
+}
+
+func testRotateKeyFlagsTargetFailsWhenBlockHeightIsLessThanTXHeight(t *testing.T) {
+	testDir, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	// given
+	f := newRotateKeyFlags(t, testDir)
+	f.TxBlockHeight = 25
+	f.TargetBlockHeight = 20
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.EqualError(t, err, "--target-height flag must be greater than --tx-height")
+	assert.Nil(t, req)
+}
+
+func newRotateKeyFlags(t *testing.T, testDir string) *cmd.RotateKeyFlags {
+	t.Helper()
+
+	_, passphraseFilePath := NewPassphraseFile(t, testDir)
+	walletName := vgrand.RandomStr(10)
+	pubKey := vgrand.RandomStr(20)
+	txBlockHeight := uint64(20)
+	targetBlockHeight := uint64(25)
+
+	return &cmd.RotateKeyFlags{
+		Wallet:            walletName,
+		PublicKey:         pubKey,
+		PassphraseFile:    passphraseFilePath,
+		TxBlockHeight:     txBlockHeight,
+		TargetBlockHeight: targetBlockHeight,
+	}
+}
diff --git a/tests/commands_for_test.go b/tests/commands_for_test.go
index 8b85b48..41217aa 100644
--- a/tests/commands_for_test.go
+++ b/tests/commands_for_test.go
@@ -324,6 +324,44 @@ func KeyTaint(t *testing.T, args []string) error {
 	return nil
 }
 
+type KeyRotateResponse struct {
+	MasterPublicKey   string `json:"masterPublicKey"`
+	Base64Transaction string `json:"base64Transaction"`
+}
+
+func KeyRotate(t *testing.T, args []string) (*KeyRotateResponse, error) {
+	t.Helper()
+	argsWithCmd := []string{"key", "rotate"}
+	argsWithCmd = append(argsWithCmd, args...)
+	output, err := ExecuteCmd(t, argsWithCmd)
+	if err != nil {
+		return nil, err
+	}
+	resp := &KeyRotateResponse{}
+	if err := json.Unmarshal(output, resp); err != nil {
+		t.Fatalf("couldn't unmarshal command output: %v", err)
+	}
+	return resp, nil
+}
+
+type KeyRotateAssertion struct {
+	t    *testing.T
+	resp *KeyRotateResponse
+}
+
+func AssertKeyRotate(t *testing.T, resp *KeyRotateResponse) *KeyRotateAssertion {
+	t.Helper()
+
+	assert.NotNil(t, resp)
+	assert.NotEmpty(t, resp.Base64Transaction)
+	assert.NotEmpty(t, resp.MasterPublicKey)
+
+	return &KeyRotateAssertion{
+		t:    t,
+		resp: resp,
+	}
+}
+
 func KeyUntaint(t *testing.T, args []string) error {
 	t.Helper()
 	argsWithCmd := []string{"key", "untaint"}
diff --git a/tests/sc_rotate_key_test.go b/tests/sc_rotate_key_test.go
new file mode 100644
index 0000000..7561a62
--- /dev/null
+++ b/tests/sc_rotate_key_test.go
@@ -0,0 +1,270 @@
+package tests_test
+
+import (
+	"testing"
+
+	vgrand "code.vegaprotocol.io/shared/libs/rand"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"code.vegaprotocol.io/vegawallet/wallet"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRotateKeySucceeds(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	generateKeyResp1, err := KeyGenerate(t, append(cmd,
+		"--meta", "name:key-1,role:validation",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertGenerateKey(t, generateKeyResp1).
+		WithWalletCreation().
+		WithName(walletName).
+		WithMeta(map[string]string{"name": "key-1", "role": "validation"}).
+		LocatedUnder(home)
+
+	// when
+	resp, err := KeyRotate(t, append(cmd,
+		"--pubkey", generateKeyResp1.Key.KeyPair.PublicKey,
+		"--tx-height", "20",
+		"--target-height", "25",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertKeyRotate(t, resp)
+}
+
+func TestRotateKeyFailsOnTainedPublicKey(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	generateKeyResp, err := KeyGenerate(t, append(cmd,
+		"--meta", "name:key-1,role:validation",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertGenerateKey(t, generateKeyResp).
+		WithWalletCreation().
+		WithName(walletName).
+		WithMeta(map[string]string{"name": "key-1", "role": "validation"}).
+		LocatedUnder(home)
+
+	// when
+	err = KeyTaint(t, append(cmd,
+		"--pubkey", generateKeyResp.Key.KeyPair.PublicKey,
+	))
+
+	// then
+	require.NoError(t, err)
+
+	// when
+	resp, err := KeyRotate(t, append(cmd,
+		"--pubkey", generateKeyResp.Key.KeyPair.PublicKey,
+		"--tx-height", "20",
+		"--target-height", "25",
+	))
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, wallet.ErrPubKeyIsTainted)
+}
+
+func TestRotateKeyFailsInIsolatedWallet(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	generateKeyResp, err := KeyGenerate(t, append(cmd,
+		"--meta", "name:key-1,role:validation",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertGenerateKey(t, generateKeyResp).
+		WithWalletCreation().
+		WithName(walletName).
+		WithMeta(map[string]string{"name": "key-1", "role": "validation"}).
+		LocatedUnder(home)
+
+	// when
+	isolateKeyResp, err := KeyIsolate(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", generateKeyResp.Key.KeyPair.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+	})
+
+	// then
+	require.NoError(t, err)
+	AssertIsolateKey(t, isolateKeyResp).
+		WithSpecialName(walletName, generateKeyResp.Key.KeyPair.PublicKey).
+		LocatedUnder(home)
+
+	// when
+	resp, err := KeyRotate(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", isolateKeyResp.Wallet,
+		"--passphrase-file", passphraseFilePath,
+		"--pubkey", generateKeyResp.Key.KeyPair.PublicKey,
+		"--tx-height", "20",
+		"--target-height", "25",
+	})
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, wallet.ErrCantRotateKeyInIsolatedWallet)
+}
+
+func TestRotateKeyFailsOnNonExitingKey(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	generateKeyResp, err := KeyGenerate(t, append(cmd,
+		"--meta", "name:key-1,role:validation",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertGenerateKey(t, generateKeyResp).
+		WithWalletCreation().
+		WithName(walletName).
+		WithMeta(map[string]string{"name": "key-1", "role": "validation"}).
+		LocatedUnder(home)
+
+	// when
+	resp, err := KeyRotate(t, append(cmd,
+		"--pubkey", "nonexisting",
+		"--tx-height", "20",
+		"--target-height", "25",
+	))
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, wallet.ErrPubKeyDoesNotExist)
+}
+
+func TestRotateKeyFailsOnNonExitingWallet(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	resp, err := KeyRotate(t, append(cmd,
+		"--pubkey", "nonexisting",
+		"--tx-height", "20",
+		"--target-height", "25",
+	))
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, wallet.ErrWalletDoesNotExists)
+}
+
+func TestRotateKeyFailsWhenTargetHeighIsLessnThanTxHeight(t *testing.T) {
+	// given
+	home, cleanUpFn := NewTempDir(t)
+	defer cleanUpFn(t)
+
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+
+	walletName := vgrand.RandomStr(5)
+
+	cmd := []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+	}
+
+	// when
+	generateKeyResp, err := KeyGenerate(t, append(cmd,
+		"--meta", "name:key-1,role:validation",
+	))
+
+	// then
+	require.NoError(t, err)
+	AssertGenerateKey(t, generateKeyResp).
+		WithWalletCreation().
+		WithName(walletName).
+		WithMeta(map[string]string{"name": "key-1", "role": "validation"}).
+		LocatedUnder(home)
+
+	// when
+	resp, err := KeyRotate(t, append(cmd,
+		"--pubkey", "nonexisting",
+		"--tx-height", "20",
+		"--target-height", "19",
+	))
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, flags.FlagRequireLessThanFlagError("tx-height", "target-height"))
+}
diff --git a/wallet/handler_test.go b/wallet/handler_test.go
index 40c4ee2..97fc269 100644
--- a/wallet/handler_test.go
+++ b/wallet/handler_test.go
@@ -1,14 +1,19 @@
 package wallet_test
 
 import (
+	"encoding/base64"
+	"encoding/hex"
 	"fmt"
 	"sort"
 	"testing"
 
+	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	vgcrypto "code.vegaprotocol.io/shared/libs/crypto"
 	vgrand "code.vegaprotocol.io/shared/libs/rand"
 	"code.vegaprotocol.io/vegawallet/wallet"
 	"code.vegaprotocol.io/vegawallet/wallet/mocks"
 	"github.com/golang/mock/gomock"
+	"github.com/golang/protobuf/proto"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -546,6 +551,142 @@ func testSignMessageWithNonExistingWalletFails(t *testing.T) {
 	assert.Nil(t, resp)
 }
 
+func TestRotateKey(t *testing.T) {
+	t.Run("Rotate key succeeds", testRotateKeySucceeds)
+	t.Run("Rotate key with non existing wallet fails", testRotateWithNonExistingWalletFails)
+	t.Run("Rotate key with non existing public key fails", testRotateKeyWithNonExistingPublicKeyFails)
+	t.Run("Rotate key tained public key fails", testRotateKeyWithTainedPublicKeyFails)
+}
+
+func testRotateKeySucceeds(t *testing.T) {
+	// given
+	w := importWalletWithKey(t)
+	kp := w.ListKeyPairs()[0]
+
+	masterKeyPair, err := w.GetMasterKeyPair()
+	require.NoError(t, err)
+
+	req := &wallet.RotateKeyRequest{
+		Wallet:            w.Name(),
+		Passphrase:        "passphrase",
+		PublicKey:         kp.PublicKey(),
+		TxBlockHeight:     20,
+		TargetBlockHeight: 25,
+	}
+
+	expectedNewPubHash := hex.EncodeToString(vgcrypto.Hash([]byte(req.PublicKey)))
+
+	// setup
+	store := handlerMocks(t)
+	store.EXPECT().WalletExists(req.Wallet).Times(1).Return(true)
+	store.EXPECT().GetWallet(req.Wallet, req.Passphrase).Times(1).Return(w, nil)
+
+	// when
+	resp, err := wallet.RotateKey(store, req)
+
+	// then
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.Equal(t, masterKeyPair.PublicKey(), resp.MasterPublicKey)
+
+	transactionRaw, err := base64.StdEncoding.DecodeString(resp.Base64Transaction)
+	require.NoError(t, err)
+
+	transaction := &commandspb.Transaction{}
+	err = proto.Unmarshal(transactionRaw, transaction)
+	require.NoError(t, err)
+
+	inputData := &commandspb.InputData{}
+	err = proto.Unmarshal(transaction.InputData, inputData)
+	require.NoError(t, err)
+
+	keyRotate, ok := inputData.Command.(*commandspb.InputData_KeyRotateSubmission)
+	require.True(t, ok)
+	require.NotNil(t, keyRotate)
+
+	require.Equal(t, req.TxBlockHeight, inputData.BlockHeight)
+	require.Equal(t, kp.Index(), keyRotate.KeyRotateSubmission.KeyNumber)
+	require.Equal(t, req.TargetBlockHeight, keyRotate.KeyRotateSubmission.TargetBlock)
+	require.Equal(t, expectedNewPubHash, keyRotate.KeyRotateSubmission.NewPubKeyHash)
+}
+
+func testRotateWithNonExistingWalletFails(t *testing.T) {
+	// given
+	req := &wallet.RotateKeyRequest{
+		Wallet:            vgrand.RandomStr(5),
+		Passphrase:        "passphrase",
+		PublicKey:         "nonexisting",
+		TxBlockHeight:     20,
+		TargetBlockHeight: 25,
+	}
+
+	// setup
+	store := handlerMocks(t)
+	store.EXPECT().WalletExists(req.Wallet).Times(1).Return(false)
+	store.EXPECT().GetWallet(req.Wallet, req.Passphrase).Times(0)
+
+	// when
+	resp, err := wallet.RotateKey(store, req)
+
+	// then
+	require.Error(t, err)
+	assert.Nil(t, resp)
+}
+
+func testRotateKeyWithNonExistingPublicKeyFails(t *testing.T) {
+	// given
+	w := importWalletWithKey(t)
+
+	req := &wallet.RotateKeyRequest{
+		Wallet:            w.Name(),
+		Passphrase:        "passphrase",
+		PublicKey:         "nonexisting",
+		TxBlockHeight:     20,
+		TargetBlockHeight: 25,
+	}
+
+	// setup
+	store := handlerMocks(t)
+	store.EXPECT().WalletExists(req.Wallet).Times(1).Return(true)
+	store.EXPECT().GetWallet(req.Wallet, req.Passphrase).Times(1).Return(w, nil)
+
+	// when
+	resp, err := wallet.RotateKey(store, req)
+
+	// then
+	require.Nil(t, resp)
+	require.Error(t, err)
+}
+
+func testRotateKeyWithTainedPublicKeyFails(t *testing.T) {
+	// given
+	w := importWalletWithKey(t)
+	kp := w.ListKeyPairs()[0]
+
+	err := w.TaintKey(kp.PublicKey())
+	require.NoError(t, err)
+
+	req := &wallet.RotateKeyRequest{
+		Wallet:            w.Name(),
+		Passphrase:        "passphrase",
+		PublicKey:         kp.PublicKey(),
+		TxBlockHeight:     20,
+		TargetBlockHeight: 25,
+	}
+
+	// setup
+	store := handlerMocks(t)
+	store.EXPECT().WalletExists(req.Wallet).Times(1).Return(true)
+	store.EXPECT().GetWallet(req.Wallet, req.Passphrase).Times(1).Return(w, nil)
+
+	// when
+	resp, err := wallet.RotateKey(store, req)
+
+	// then
+	require.Nil(t, resp)
+	require.ErrorIs(t, err, wallet.ErrPubKeyIsTainted)
+}
+
 func newWalletWithKey(t *testing.T) *wallet.HDWallet {
 	t.Helper()
 	return newWalletWithKeys(t, 1)
diff --git a/wallet/hd_master_key_pair_test.go b/wallet/hd_master_key_pair_test.go
new file mode 100644
index 0000000..7c8366a
--- /dev/null
+++ b/wallet/hd_master_key_pair_test.go
@@ -0,0 +1,86 @@
+package wallet_test
+
+import (
+	"encoding/hex"
+	"testing"
+
+	"code.vegaprotocol.io/vegawallet/wallet"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestHDMasterKeypair(t *testing.T) {
+	t.Run("New master key pair succeeds", testHDMasterKeyPairNewKeyPairSucceeds)
+	t.Run("Signing transaction succeeds", testHDMasterKeyPairSigningAnyMessageSucceeds)
+	t.Run("Signing any message succeeds", testHDMasterKeyPairSigningTransactionSucceeds)
+}
+
+func testHDMasterKeyPairNewKeyPairSucceeds(t *testing.T) {
+	// given
+	publicKey := "e20fcd0aa4cc2ea7c18d55dc083d14006377295418f657ba52593eae14e3a98a"
+	rawPublicKey, err := hex.DecodeString(publicKey)
+	if err != nil {
+		panic(err)
+	}
+	privateKey := "c55ae95741a0431186bb062501ad9c5a31505fc14d9fd91f08108b11fbec33d9e20fcd0aa4cc2ea7c18d55dc083d14006377295418f657ba52593eae14e3a98a"
+	rawPrivateKey, err := hex.DecodeString(privateKey)
+	if err != nil {
+		panic(err)
+	}
+
+	// when
+	kp, err := wallet.NewHDMasterKeyPair(rawPublicKey, rawPrivateKey)
+
+	// then
+	require.NoError(t, err)
+	assert.NotNil(t, kp)
+	assert.Equal(t, publicKey, kp.PublicKey())
+	assert.Equal(t, privateKey, kp.PrivateKey())
+	assert.Equal(t, "vega/ed25519", kp.AlgorithmName())
+	assert.Equal(t, uint32(1), kp.AlgorithmVersion())
+}
+
+func testHDMasterKeyPairSigningTransactionSucceeds(t *testing.T) {
+	// given
+	kp := getHDMasterKeyPair(t)
+	data := []byte("Paul Atreides")
+
+	// when
+	sig, err := kp.Sign(data)
+
+	// then
+	require.NoError(t, err)
+	assert.Equal(t, "2ffd9c1a5c28007eb5fe2fbf7be446cf00d6edee2131a658f4a0424b7fc4cd8ef6a3237a0a9d0355e80eabb2dd2716638a5c545a3b9a2ca4a6c5d26898070501", sig.Value)
+	assert.Equal(t, kp.AlgorithmName(), sig.Algo)
+	assert.Equal(t, kp.AlgorithmVersion(), sig.Version)
+}
+
+func testHDMasterKeyPairSigningAnyMessageSucceeds(t *testing.T) {
+	// given
+	kp := getHDMasterKeyPair(t)
+	data := []byte("Paul Atreides")
+
+	// when
+	sig, err := kp.SignAny(data)
+
+	// then
+	require.NoError(t, err)
+	assert.Equal(t, []byte{0x2f, 0xfd, 0x9c, 0x1a, 0x5c, 0x28, 0x0, 0x7e, 0xb5, 0xfe, 0x2f, 0xbf, 0x7b, 0xe4, 0x46, 0xcf, 0x0, 0xd6, 0xed, 0xee, 0x21, 0x31, 0xa6, 0x58, 0xf4, 0xa0, 0x42, 0x4b, 0x7f, 0xc4, 0xcd, 0x8e, 0xf6, 0xa3, 0x23, 0x7a, 0xa, 0x9d, 0x3, 0x55, 0xe8, 0xe, 0xab, 0xb2, 0xdd, 0x27, 0x16, 0x63, 0x8a, 0x5c, 0x54, 0x5a, 0x3b, 0x9a, 0x2c, 0xa4, 0xa6, 0xc5, 0xd2, 0x68, 0x98, 0x7, 0x5, 0x1}, sig)
+}
+
+func getHDMasterKeyPair(t *testing.T) *wallet.HDMasterKeyPair {
+	t.Helper()
+
+	publicKey := "e20fcd0aa4cc2ea7c18d55dc083d14006377295418f657ba52593eae14e3a98a"
+	rawPublicKey, err := hex.DecodeString(publicKey)
+	assert.NoError(t, err)
+
+	privateKey := "c55ae95741a0431186bb062501ad9c5a31505fc14d9fd91f08108b11fbec33d9e20fcd0aa4cc2ea7c18d55dc083d14006377295418f657ba52593eae14e3a98a"
+	rawPrivateKey, err := hex.DecodeString(privateKey)
+	assert.NoError(t, err)
+
+	kp, err := wallet.NewHDMasterKeyPair(rawPublicKey, rawPrivateKey)
+	assert.NoError(t, err)
+
+	return kp
+}
diff --git a/wallet/hd_wallet_test.go b/wallet/hd_wallet_test.go
index 22bcc1f..f72c154 100644
--- a/wallet/hd_wallet_test.go
+++ b/wallet/hd_wallet_test.go
@@ -49,6 +49,7 @@ func TestHDWallet(t *testing.T) {
 	t.Run("Isolating wallet succeeds", testHDWalletIsolatingWalletSucceeds)
 	t.Run("Isolating wallet with tainted key pair fails", testHDWalletIsolatingWalletWithTaintedKeyPairFails)
 	t.Run("Isolating wallet with non-existing key pair fails", testHDWalletIsolatingWalletWithNonExistingKeyPairFails)
+	t.Run("Getting master key pair succeeds", testHDWalletGettingWalletMasterKeySucceeds)
 }
 
 func testHDWalletCreateWalletSucceeds(t *testing.T) {
@@ -1510,3 +1511,38 @@ func testHDWalletIsolatingWalletWithNonExistingKeyPairFails(t *testing.T) {
 		})
 	}
 }
+
+func testHDWalletGettingWalletMasterKeySucceeds(t *testing.T) {
+	tcs := []struct {
+		name    string
+		version uint32
+	}{
+		{
+			name:    "version 1",
+			version: 1,
+		}, {
+			name:    "version 2",
+			version: 2,
+		},
+	}
+
+	for _, tc := range tcs {
+		t.Run(tc.name, func(tt *testing.T) {
+			// given
+			name := "duncan"
+
+			// when
+			w, err := wallet.ImportHDWallet(name, TestMnemonic1, tc.version)
+			require.NoError(tt, err)
+			require.NotNil(tt, w)
+
+			masterKeyPair, err := w.GetMasterKeyPair()
+
+			// then
+			require.NoError(tt, err)
+			assert.Equal(tt, "9df682a3c87d90567f260566a9c223ccbbb7529c38340cf163b8fe199dbf0f2e", masterKeyPair.PublicKey())
+			assert.Equal(tt, "3e323acf112ee1db5c4eef76758941ff60dafabbd2a60ecacef98b310f70bfa89df682a3c87d90567f260566a9c223ccbbb7529c38340cf163b8fe199dbf0f2e", masterKeyPair.PrivateKey())
+			assert.Equal(tt, "HD wallet", w.Type())
+		})
+	}
+}