Repository: vegaprotocol/vegawallet (go)

Issue:
Allow signing command offline
Some users want to sign transactions offline on air-gaped computer for extra security.

We can do something like:

```sh
vegawallet command sign --wallet WALLET --pubkey PUBKEY --command COMMAND
```

To ease the export, we will output as a base64, so it easily portable.

Pull request:
Support signing commands offline
Close #431 

Patch:
diff --git a/.github/workflows/add-issue-to-team-board.yml b/.github/workflows/add-issue-to-team-board.yml
index 005b68f..047c672 100644
--- a/.github/workflows/add-issue-to-team-board.yml
+++ b/.github/workflows/add-issue-to-team-board.yml
@@ -2,7 +2,8 @@ name: Add issue to team board
 
 on:
   issues:
-    types: [ opened ]
+    types:
+      - opened
 
 jobs:
   add-to-team-board:
diff --git a/.github/workflows/add-issues-to-sprint-board.yml b/.github/workflows/add-issues-to-sprint-board.yml
index 6b0ec4a..cb9d639 100644
--- a/.github/workflows/add-issues-to-sprint-board.yml
+++ b/.github/workflows/add-issues-to-sprint-board.yml
@@ -2,7 +2,8 @@ name: Add issue to sprint board
 
 on:
   issues:
-    types: [ opened ]
+    types:
+      - opened
 
 jobs:
   add-to-sprint-board:
diff --git a/.github/workflows/build-binaries.yml b/.github/workflows/build-binaries.yml
index c363089..30d1f21 100644
--- a/.github/workflows/build-binaries.yml
+++ b/.github/workflows/build-binaries.yml
@@ -2,9 +2,12 @@ name: Build binaries
 
 on:
   push:
-    branches: [ master ]
+    branches:
+      - master
   pull_request:
-    branches: [ master, develop ]
+    branches:
+      - master
+      - develop
 
 jobs:
   build:
diff --git a/.github/workflows/build-docker-image.yml b/.github/workflows/build-docker-image.yml
index 0d5f2d3..0626533 100644
--- a/.github/workflows/build-docker-image.yml
+++ b/.github/workflows/build-docker-image.yml
@@ -4,7 +4,6 @@ on:
   pull_request:
     branches:
       - develop
-      - main
       - master
   push:
     branches:
@@ -14,7 +13,6 @@ on:
       - "v[0-9]+.[0-9]+.[0-9]+-pre[0-9]+"
 
 jobs:
-
   build:
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/label-issues.yml b/.github/workflows/label-issues.yml
index d6bf694..5c1a684 100644
--- a/.github/workflows/label-issues.yml
+++ b/.github/workflows/label-issues.yml
@@ -4,6 +4,7 @@ on:
     types:
       - opened
       - reopened
+      -
 jobs:
   label_issues:
     runs-on: ubuntu-latest
diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml
index dc9cadc..18c66e0 100644
--- a/.github/workflows/release-binaries.yml
+++ b/.github/workflows/release-binaries.yml
@@ -12,7 +12,9 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        arch: [ amd64, arm64 ]
+        arch:
+          - amd64
+          - arm64
     env:
       GOARCH: ${{ matrix.arch }}
       CGO_ENABLED: 0
@@ -61,7 +63,9 @@ jobs:
     runs-on: macos-latest
     strategy:
       matrix:
-        arch: [ amd64, arm64 ]
+        arch:
+          - amd64
+          - arm64
     env:
       GOARCH: ${{ matrix.arch }}
       CGO_ENABLED: 0
@@ -133,7 +137,9 @@ jobs:
     runs-on: windows-latest
     strategy:
       matrix:
-        arch: [ amd64, arm64 ]
+        arch:
+          - amd64
+          - arm64
     env:
       GOARCH: ${{ matrix.arch }}
       CGO_ENABLED: 0
diff --git a/.github/workflows/search-for-code-offences.yml b/.github/workflows/search-for-code-offences.yml
index 3be907e..1125b26 100644
--- a/.github/workflows/search-for-code-offences.yml
+++ b/.github/workflows/search-for-code-offences.yml
@@ -6,7 +6,6 @@ on:
       - v*
     branches:
       - master
-      - main
       - develop
   pull_request:
 
@@ -19,23 +18,4 @@ jobs:
       - name: golangci-lint
         uses: golangci/golangci-lint-action@5c56cd6c9dc07901af25baab6f2b0d9f3b7c3018 # hash of v2.5.2
         with:
-          # Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
           version: v1.42.1
-
-          # Optional: working directory, useful for monorepos
-          # working-directory: somedir
-
-          # Optional: golangci-lint command line arguments.
-          # args: --issues-exit-code=0
-
-          # Optional: show only new issues if it's a pull request. The default value is `false`.
-          # only-new-issues: true
-
-          # Optional: if set to true then the action will use pre-installed Go.
-          # skip-go-installation: true
-
-          # Optional: if set to true then the action don't cache or restore ~/go/pkg.
-          # skip-pkg-cache: true
-
-          # Optional: if set to true then the action don't cache or restore ~/.cache/go-build.
-          # skip-build-cache: true
diff --git a/.github/workflows/verify-changelog-is-updated.yml b/.github/workflows/verify-changelog-is-updated.yml
index 528c3d3..ab5bb39 100644
--- a/.github/workflows/verify-changelog-is-updated.yml
+++ b/.github/workflows/verify-changelog-is-updated.yml
@@ -2,10 +2,10 @@ name: Verify CHANGELOG is updated
 
 on:
   pull_request:
-    types: [ review_requested, ready_for_review ]
     branches:
-      - develop
       - master
+      - develop
+  check_run:
 
 jobs:
   changelog-checker:
diff --git a/.github/workflows/verify-pr-has-linked-issue.yml b/.github/workflows/verify-pr-has-linked-issue.yml
index 415852b..ce103aa 100644
--- a/.github/workflows/verify-pr-has-linked-issue.yml
+++ b/.github/workflows/verify-pr-has-linked-issue.yml
@@ -2,7 +2,11 @@ name: Verify PR has a linked issue
 
 on:
   pull_request:
-    types: [ edited, synchronize, opened, reopened ]
+    types:
+      - edited
+      - synchronize
+      - opened
+      - reopened
   check_run:
 
 jobs:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3729b3e..95bb180 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,7 +12,7 @@
 - [](https://github.com/vegaprotocol/vegawallet/pull/) -
 
 ### 🔥 Removal
-- [](https://github.com/vegaprotocol/vegawallet/pull/) -
+- [435](https://github.com/vegaprotocol/vegawallet/pull/435)  Support signing commands offline with `command sign`
 
 ### 🐛 Fixes
 - [](https://github.com/vegaprotocol/vegawallet/pull/) -
diff --git a/cmd/command.go b/cmd/command.go
index c0eb471..5bda607 100644
--- a/cmd/command.go
+++ b/cmd/command.go
@@ -14,5 +14,6 @@ func NewCmdCommand(w io.Writer, rf *RootFlags) *cobra.Command {
 	}
 
 	cmd.AddCommand(NewCmdCommandSend(w, rf))
+	cmd.AddCommand(NewCmdCommandSign(w, rf))
 	return cmd
 }
diff --git a/cmd/command_sign.go b/cmd/command_sign.go
new file mode 100644
index 0000000..904ef8b
--- /dev/null
+++ b/cmd/command_sign.go
@@ -0,0 +1,182 @@
+package cmd
+
+import (
+	"fmt"
+	"io"
+	"os"
+
+	walletpb "code.vegaprotocol.io/protos/vega/wallet/v1"
+	"code.vegaprotocol.io/vegawallet/cmd/cli"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"code.vegaprotocol.io/vegawallet/cmd/printer"
+	wcommands "code.vegaprotocol.io/vegawallet/commands"
+	"code.vegaprotocol.io/vegawallet/wallet"
+	"code.vegaprotocol.io/vegawallet/wallets"
+	"github.com/golang/protobuf/jsonpb"
+	"github.com/spf13/cobra"
+)
+
+var (
+	signCommandLong = cli.LongDesc(`
+		Sign a command using the specified wallet and public key and bundle it as a
+		transaction ready to be sent. The resulting transaction is base64-encoded and
+		can be sent using the command "tx send".
+
+		The command should be a Vega command formatted as a JSON payload, as follows:
+
+		'{"commandName": {"someProperty": "someValue"} }'
+
+		For vote submission, it will look like this:
+
+		'{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}'
+	`)
+
+	signCommandExample = cli.Examples(`
+		# Sign a command
+		vegawallet command sign --wallet WALLET --pubkey PUBKEY --tx-height TX_HEIGHT COMMAND
+
+		# To decode the result, save the result in a file and use the command 
+		# "base64"
+		vegawallet command sign --wallet WALLET --pubkey PUBKEY --tx-height TX_HEIGHT COMMAND > result.txt
+		base64 --decode --input result.txt
+	`)
+)
+
+type SignCommandHandler func(*wallet.SignCommandRequest) (*wallet.SignCommandResponse, error)
+
+func NewCmdCommandSign(w io.Writer, rf *RootFlags) *cobra.Command {
+	handler := func(req *wallet.SignCommandRequest) (*wallet.SignCommandResponse, error) {
+		store, err := wallets.InitialiseStore(rf.Home)
+		if err != nil {
+			return nil, fmt.Errorf("couldn't initialise wallets store: %w", err)
+		}
+
+		return wallet.SignCommand(store, req)
+	}
+
+	return BuildCmdCommandSign(w, handler, rf)
+}
+
+func BuildCmdCommandSign(w io.Writer, handler SignCommandHandler, rf *RootFlags) *cobra.Command {
+	f := &SignCommandFlags{}
+
+	cmd := &cobra.Command{
+		Use:     "sign",
+		Short:   "Sign a command for offline use",
+		Long:    signCommandLong,
+		Example: signCommandExample,
+		RunE: func(_ *cobra.Command, args []string) error {
+			if aLen := len(args); aLen == 0 {
+				return flags.ArgMustBeSpecifiedError("command")
+			} else if aLen > 1 {
+				return flags.TooManyArgsError("command")
+			}
+			f.RawCommand = args[0]
+
+			req, err := f.Validate()
+			if err != nil {
+				return err
+			}
+
+			resp, err := handler(req)
+			if err != nil {
+				return err
+			}
+
+			switch rf.Output {
+			case flags.InteractiveOutput:
+				PrintSignCommandResponse(w, resp)
+			case flags.JSONOutput:
+				return printer.FprintJSON(w, resp)
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVarP(&f.Wallet,
+		"wallet", "w",
+		"",
+		"Wallet holding the public key",
+	)
+	cmd.Flags().StringVarP(&f.PubKey,
+		"pubkey", "k",
+		"",
+		"Public key of the key pair to use for signing (hex-encoded)",
+	)
+	cmd.Flags().StringVarP(&f.PassphraseFile,
+		"passphrase-file", "p",
+		"",
+		"Path to the file containing the wallet's passphrase",
+	)
+	cmd.Flags().Uint64Var(&f.TxBlockHeight,
+		"tx-height",
+		0,
+		"It should be close to the current block height when the transaction is applied, with a threshold of ~ - 150 blocks.",
+	)
+
+	autoCompleteWallet(cmd, rf.Home)
+
+	return cmd
+}
+
+type SignCommandFlags struct {
+	Wallet         string
+	PubKey         string
+	PassphraseFile string
+	RawCommand     string
+	TxBlockHeight  uint64
+}
+
+func (f *SignCommandFlags) Validate() (*wallet.SignCommandRequest, error) {
+	req := &wallet.SignCommandRequest{}
+
+	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
+
+	if f.TxBlockHeight == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("tx-height")
+	}
+	req.TxBlockHeight = f.TxBlockHeight
+
+	if len(f.PubKey) == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("pubkey")
+	}
+	if len(f.RawCommand) == 0 {
+		return nil, flags.ArgMustBeSpecifiedError("command")
+	}
+	request := &walletpb.SubmitTransactionRequest{}
+	if err := jsonpb.UnmarshalString(f.RawCommand, request); err != nil {
+		return nil, fmt.Errorf("couldn't unmarshal command as request: %w", err)
+	}
+	if len(request.PubKey) != 0 {
+		return nil, ErrDoNotSetPubKeyInCommand
+	}
+	request.PubKey = f.PubKey
+	request.Propagate = true
+	req.Request = request
+	if errs := wcommands.CheckSubmitTransactionRequest(req.Request); !errs.Empty() {
+		return nil, fmt.Errorf("invalid request: %w", errs)
+	}
+
+	return req, nil
+}
+
+func PrintSignCommandResponse(w io.Writer, req *wallet.SignCommandResponse) {
+	p := printer.NewInteractivePrinter(w)
+
+	p.CheckMark().SuccessText("Command signature successful").NextSection()
+	p.Text("Transaction (base64-encoded):").NextLine().WarningText(req.Base64Transaction).NextSection()
+
+	p.BlueArrow().InfoText("Send a transaction").NextLine()
+	p.Text("To send a raw transaction, see the following command:").NextSection()
+	p.Code(fmt.Sprintf("%s tx send --help", os.Args[0])).NextSection()
+}
diff --git a/cmd/key_rotate.go b/cmd/key_rotate.go
index 1f118c1..736c462 100644
--- a/cmd/key_rotate.go
+++ b/cmd/key_rotate.go
@@ -160,7 +160,7 @@ func (f *RotateKeyFlags) Validate() (*wallet.RotateKeyRequest, error) {
 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("Transaction (base64-encoded):").NextLine()
 	p.Text(req.Base64Transaction).NextLine()
 	p.Text("Master public key used:").NextLine()
 	p.Text(req.MasterPublicKey).NextLine()
diff --git a/wallet/handler.go b/wallet/handler.go
index b67f9b3..1d47d56 100644
--- a/wallet/handler.go
+++ b/wallet/handler.go
@@ -7,6 +7,8 @@ import (
 
 	"code.vegaprotocol.io/protos/commands"
 	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	walletpb "code.vegaprotocol.io/protos/vega/wallet/v1"
+	wcommands "code.vegaprotocol.io/vegawallet/commands"
 	"github.com/golang/protobuf/proto"
 )
 
@@ -499,6 +501,52 @@ func ListWallets(store Store) (*ListWalletsResponse, error) {
 	return resp, nil
 }
 
+type SignCommandRequest struct {
+	Wallet        string
+	Passphrase    string
+	TxBlockHeight uint64
+	Request       *walletpb.SubmitTransactionRequest
+}
+
+type SignCommandResponse struct {
+	Base64Transaction string `json:"base64Transaction"`
+}
+
+func SignCommand(store Store, req *SignCommandRequest) (*SignCommandResponse, error) {
+	w, err := getWallet(store, req.Wallet, req.Passphrase)
+	if err != nil {
+		return nil, err
+	}
+
+	data, err := wcommands.ToMarshaledInputData(req.Request, req.TxBlockHeight)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't marshal input data: %w", err)
+	}
+
+	pubKey := req.Request.GetPubKey()
+	signature, err := w.SignTx(pubKey, data)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't sign transaction: %w", err)
+	}
+
+	protoSignature := &commandspb.Signature{
+		Value:   signature.Value,
+		Algo:    signature.Algo,
+		Version: signature.Version,
+	}
+
+	tx := commands.NewTransaction(pubKey, data, protoSignature)
+
+	rawTx, err := proto.Marshal(tx)
+	if err != nil {
+		return nil, fmt.Errorf("couldn't marshal transaction: %w", err)
+	}
+
+	return &SignCommandResponse{
+		Base64Transaction: base64.StdEncoding.EncodeToString(rawTx),
+	}, nil
+}
+
 type SignMessageRequest struct {
 	Wallet     string
 	PubKey     string


Test patch:
diff --git a/cmd/command_send_test.go b/cmd/command_send_test.go
index ca1d86d..96002d8 100644
--- a/cmd/command_send_test.go
+++ b/cmd/command_send_test.go
@@ -187,7 +187,7 @@ func testSendCommandFlagsMalformedRequestFails(t *testing.T) {
 
 	// given
 	f := newSendCommandFlags(t, testDir)
-	f.RawCommand = `<request>Not gonna support that</request>`
+	f.RawCommand = vgrand.RandomStr(5)
 
 	// when
 	req, err := f.Validate()
diff --git a/cmd/command_sign_test.go b/cmd/command_sign_test.go
new file mode 100644
index 0000000..8c19953
--- /dev/null
+++ b/cmd/command_sign_test.go
@@ -0,0 +1,188 @@
+package cmd_test
+
+import (
+	"testing"
+
+	"code.vegaprotocol.io/protos/vega"
+	v1 "code.vegaprotocol.io/protos/vega/commands/v1"
+	walletpb "code.vegaprotocol.io/protos/vega/wallet/v1"
+	vgrand "code.vegaprotocol.io/shared/libs/rand"
+	"code.vegaprotocol.io/vegawallet/cmd"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	wcommands "code.vegaprotocol.io/vegawallet/wallet"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSignCommandFlags(t *testing.T) {
+	t.Run("Valid flags succeeds", testSignCommandFlagsValidFlagsSucceeds)
+	t.Run("Missing wallet fails", testSignCommandFlagsMissingWalletFails)
+	t.Run("Missing public key fails", testSignCommandFlagsMissingPubKeyFails)
+	t.Run("Missing tx height fails", testSignCommandFlagsMissingTxBlockHeightFails)
+	t.Run("Missing request fails", testSignCommandFlagsMissingRequestFails)
+	t.Run("Malformed request fails", testSignCommandFlagsMalformedRequestFails)
+	t.Run("Invalid request fails", testSignCommandFlagsInvalidRequestFails)
+	t.Run("Request with public key set in it fails", testSignCommandFlagsRequestWithPubKeyFails)
+}
+
+func testSignCommandFlagsValidFlagsSucceeds(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	passphrase, passphraseFilePath := NewPassphraseFile(t, testDir)
+	walletName := vgrand.RandomStr(10)
+	pubKey := vgrand.RandomStr(20)
+
+	f := &cmd.SignCommandFlags{
+		Wallet:         walletName,
+		PubKey:         pubKey,
+		PassphraseFile: passphraseFilePath,
+		TxBlockHeight:  150,
+		RawCommand:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+	}
+
+	expectedReq := &wcommands.SignCommandRequest{
+		Wallet:        walletName,
+		Passphrase:    passphrase,
+		TxBlockHeight: 150,
+		Request: &walletpb.SubmitTransactionRequest{
+			PubKey:    pubKey,
+			Propagate: true,
+			Command: &walletpb.SubmitTransactionRequest_VoteSubmission{
+				VoteSubmission: &v1.VoteSubmission{
+					ProposalId: "some-id",
+					Value:      vega.Vote_VALUE_YES,
+				},
+			},
+		},
+	}
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	require.NoError(t, err)
+	require.NotNil(t, req)
+	assert.Equal(t, expectedReq, req)
+}
+
+func testSignCommandFlagsMissingWalletFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.Wallet = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("wallet"))
+	assert.Nil(t, req)
+}
+
+func testSignCommandFlagsMissingPubKeyFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.PubKey = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("pubkey"))
+	assert.Nil(t, req)
+}
+
+func testSignCommandFlagsMissingTxBlockHeightFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// 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 testSignCommandFlagsMissingRequestFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.RawCommand = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.ArgMustBeSpecifiedError("command"))
+	assert.Nil(t, req)
+}
+
+func testSignCommandFlagsMalformedRequestFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.RawCommand = vgrand.RandomStr(5)
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.Error(t, err)
+	assert.Nil(t, req)
+}
+
+func testSignCommandFlagsInvalidRequestFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.RawCommand = `{"voteSubmission": {}}`
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.Error(t, err)
+	assert.Nil(t, req)
+}
+
+func testSignCommandFlagsRequestWithPubKeyFails(t *testing.T) {
+	testDir := t.TempDir()
+
+	// given
+	f := newSignCommandFlags(t, testDir)
+	f.RawCommand = `{"pubKey": "qwerty123456", "voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, cmd.ErrDoNotSetPubKeyInCommand)
+	assert.Nil(t, req)
+}
+
+func newSignCommandFlags(t *testing.T, testDir string) *cmd.SignCommandFlags {
+	t.Helper()
+
+	_, passphraseFilePath := NewPassphraseFile(t, testDir)
+	walletName := vgrand.RandomStr(10)
+	pubKey := vgrand.RandomStr(20)
+
+	return &cmd.SignCommandFlags{
+		RawCommand:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+		Wallet:         walletName,
+		PubKey:         pubKey,
+		TxBlockHeight:  150,
+		PassphraseFile: passphraseFilePath,
+	}
+}
diff --git a/cmd/tx_send_test.go b/cmd/tx_send_test.go
index c1ebbc2..3727861 100644
--- a/cmd/tx_send_test.go
+++ b/cmd/tx_send_test.go
@@ -131,7 +131,7 @@ func testSendTxFlagsMissingTxFails(t *testing.T) {
 func testSendTxFlagsMalformedTxFails(t *testing.T) {
 	// given
 	f := newSendTxFlags(t)
-	f.RawTx = `<request>Not gonna support that</request>`
+	f.RawTx = vgrand.RandomStr(5)
 
 	// when
 	req, err := f.Validate()
diff --git a/tests/commands_for_test.go b/tests/commands_for_test.go
index 09def62..56020d0 100644
--- a/tests/commands_for_test.go
+++ b/tests/commands_for_test.go
@@ -567,11 +567,47 @@ func (d *DescribeNetworkAssertion) WithRESTConfig(hosts []string) *DescribeNetwo
 	return d
 }
 
+type SignCommandResponse struct {
+	Transaction string `json:"base64Transaction"`
+}
+
+func SignCommand(t *testing.T, args []string) (*SignCommandResponse, error) {
+	t.Helper()
+	argsWithCmd := []string{"command", "sign"}
+	argsWithCmd = append(argsWithCmd, args...)
+	output, err := ExecuteCmd(t, argsWithCmd)
+	if err != nil {
+		return nil, err
+	}
+	resp := &SignCommandResponse{}
+	if err := json.Unmarshal(output, resp); err != nil {
+		t.Fatalf("couldn't unmarshal command output: %v", err)
+	}
+	return resp, nil
+}
+
+type SignCommandAssertion struct {
+	t    *testing.T
+	resp *SignCommandResponse
+}
+
+func AssertSignCommand(t *testing.T, resp *SignCommandResponse) *SignCommandAssertion {
+	t.Helper()
+
+	assert.NotNil(t, resp)
+	assert.NotEmpty(t, resp.Transaction)
+
+	return &SignCommandAssertion{
+		t:    t,
+		resp: resp,
+	}
+}
+
 type SignMessageResponse struct {
 	Signature string `json:"signature"`
 }
 
-func Sign(t *testing.T, args []string) (*SignMessageResponse, error) {
+func SignMessage(t *testing.T, args []string) (*SignMessageResponse, error) {
 	t.Helper()
 	argsWithCmd := []string{"sign"}
 	argsWithCmd = append(argsWithCmd, args...)
@@ -586,24 +622,24 @@ func Sign(t *testing.T, args []string) (*SignMessageResponse, error) {
 	return resp, nil
 }
 
-type SignAssertion struct {
+type SignMessageAssertion struct {
 	t    *testing.T
 	resp *SignMessageResponse
 }
 
-func AssertSign(t *testing.T, resp *SignMessageResponse) *SignAssertion {
+func AssertSignMessage(t *testing.T, resp *SignMessageResponse) *SignMessageAssertion {
 	t.Helper()
 
 	assert.NotNil(t, resp)
 	assert.NotEmpty(t, resp.Signature)
 
-	return &SignAssertion{
+	return &SignMessageAssertion{
 		t:    t,
 		resp: resp,
 	}
 }
 
-func (a *SignAssertion) WithSignature(expected string) *SignAssertion {
+func (a *SignMessageAssertion) WithSignature(expected string) *SignMessageAssertion {
 	assert.Equal(a.t, expected, a.resp.Signature)
 	return a
 }
@@ -612,7 +648,7 @@ type VerifyMessageResponse struct {
 	IsValid bool `json:"isValid"`
 }
 
-func Verify(t *testing.T, args []string) (*VerifyMessageResponse, error) {
+func VerifyMessage(t *testing.T, args []string) (*VerifyMessageResponse, error) {
 	t.Helper()
 	argsWithCmd := []string{"verify"}
 	argsWithCmd = append(argsWithCmd, args...)
@@ -632,7 +668,7 @@ type VerifyAssertion struct {
 	resp *VerifyMessageResponse
 }
 
-func AssertVerify(t *testing.T, resp *VerifyMessageResponse) *VerifyAssertion {
+func AssertVerifyMessage(t *testing.T, resp *VerifyMessageResponse) *VerifyAssertion {
 	t.Helper()
 
 	assert.NotNil(t, resp)
diff --git a/tests/sc_sign_and_verify_command_test.go b/tests/sc_sign_and_verify_command_test.go
new file mode 100644
index 0000000..a81a37c
--- /dev/null
+++ b/tests/sc_sign_and_verify_command_test.go
@@ -0,0 +1,126 @@
+package tests_test
+
+import (
+	"testing"
+
+	vgrand "code.vegaprotocol.io/shared/libs/rand"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSignCommand(t *testing.T) {
+	// given
+	home := t.TempDir()
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+	recoveryPhraseFilePath := NewFile(t, home, "recovery-phrase.txt", testRecoveryPhrase)
+	walletName := vgrand.RandomStr(5)
+
+	// when
+	importWalletResp, err := WalletImport(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+		"--recovery-phrase-file", recoveryPhraseFilePath,
+		"--version", "2",
+	})
+
+	// then
+	require.NoError(t, err)
+	AssertImportWallet(t, importWalletResp).
+		WithName(walletName).
+		LocatedUnder(home)
+
+	// when
+	signResp, err := SignCommand(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", importWalletResp.Key.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+		"--tx-height", "150",
+		`{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+	})
+
+	// then
+	require.NoError(t, err)
+	AssertSignCommand(t, signResp)
+}
+
+func TestSignCommandWithTaintedKey(t *testing.T) {
+	// given
+	home := t.TempDir()
+	_, passphraseFilePath := NewPassphraseFile(t, home)
+	recoveryPhraseFilePath := NewFile(t, home, "recovery-phrase.txt", testRecoveryPhrase)
+	walletName := vgrand.RandomStr(5)
+
+	// when
+	importWalletResp, err := WalletImport(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--passphrase-file", passphraseFilePath,
+		"--recovery-phrase-file", recoveryPhraseFilePath,
+		"--version", "2",
+	})
+
+	// then
+	require.NoError(t, err)
+	AssertImportWallet(t, importWalletResp).
+		WithName(walletName).
+		WithPublicKey("b5fd9d3c4ad553cb3196303b6e6df7f484cf7f5331a572a45031239fd71ad8a0").
+		LocatedUnder(home)
+
+	// when
+	err = KeyTaint(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", importWalletResp.Key.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+	})
+
+	// then
+	require.NoError(t, err)
+
+	// when
+	signResp, err := SignCommand(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", importWalletResp.Key.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+		"--tx-height", "150",
+		`{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+	})
+
+	// then
+	require.EqualError(t, err, "couldn't sign transaction: public key is tainted")
+	require.Nil(t, signResp)
+
+	// when
+	err = KeyUntaint(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", importWalletResp.Key.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+	})
+
+	// then
+	require.NoError(t, err)
+
+	// when
+	signResp, err = SignCommand(t, []string{
+		"--home", home,
+		"--output", "json",
+		"--wallet", walletName,
+		"--pubkey", importWalletResp.Key.PublicKey,
+		"--passphrase-file", passphraseFilePath,
+		"--tx-height", "150",
+		`{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+	})
+
+	// then
+	require.NoError(t, err)
+	AssertSignCommand(t, signResp)
+}
diff --git a/tests/sc_sign_and_verify_message_test.go b/tests/sc_sign_and_verify_message_test.go
index 1eca346..fbc3079 100644
--- a/tests/sc_sign_and_verify_message_test.go
+++ b/tests/sc_sign_and_verify_message_test.go
@@ -36,7 +36,7 @@ func TestSignMessage(t *testing.T) {
 	encodedMessage := base64.StdEncoding.EncodeToString(message)
 
 	// when
-	signResp, err := Sign(t, []string{
+	signResp, err := SignMessage(t, []string{
 		"--home", home,
 		"--output", "json",
 		"--wallet", walletName,
@@ -47,11 +47,11 @@ func TestSignMessage(t *testing.T) {
 
 	// then
 	require.NoError(t, err)
-	AssertSign(t, signResp).
+	AssertSignMessage(t, signResp).
 		WithSignature("StH82RHxjQ3yTeaSN25b6sJwAyLiq1CDvPWf0X4KIf/WTIjkunkWKn1Gq9ntCoGBfBZIyNfpPtGx0TSZsSrbCA==")
 
 	// when
-	verifyResp, err := Verify(t, []string{
+	verifyResp, err := VerifyMessage(t, []string{
 		"--home", home,
 		"--output", "json",
 		"--pubkey", importWalletResp.Key.PublicKey,
@@ -61,7 +61,7 @@ func TestSignMessage(t *testing.T) {
 
 	// then
 	require.NoError(t, err)
-	AssertVerify(t, verifyResp).IsValid()
+	AssertVerifyMessage(t, verifyResp).IsValid()
 }
 
 func TestSignMessageWithTaintedKey(t *testing.T) {
@@ -105,7 +105,7 @@ func TestSignMessageWithTaintedKey(t *testing.T) {
 	encodedMessage := base64.StdEncoding.EncodeToString(message)
 
 	// when
-	signResp, err := Sign(t, []string{
+	signResp, err := SignMessage(t, []string{
 		"--home", home,
 		"--output", "json",
 		"--wallet", walletName,
@@ -131,7 +131,7 @@ func TestSignMessageWithTaintedKey(t *testing.T) {
 	require.NoError(t, err)
 
 	// when
-	signResp, err = Sign(t, []string{
+	signResp, err = SignMessage(t, []string{
 		"--home", home,
 		"--output", "json",
 		"--wallet", walletName,
@@ -142,6 +142,6 @@ func TestSignMessageWithTaintedKey(t *testing.T) {
 
 	// then
 	require.NoError(t, err)
-	AssertSign(t, signResp).
+	AssertSignMessage(t, signResp).
 		WithSignature("StH82RHxjQ3yTeaSN25b6sJwAyLiq1CDvPWf0X4KIf/WTIjkunkWKn1Gq9ntCoGBfBZIyNfpPtGx0TSZsSrbCA==")
 }
diff --git a/wallet/handler_test.go b/wallet/handler_test.go
index a99c59b..173a9b1 100644
--- a/wallet/handler_test.go
+++ b/wallet/handler_test.go
@@ -6,7 +6,9 @@ import (
 	"sort"
 	"testing"
 
+	"code.vegaprotocol.io/protos/vega"
 	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	walletpb "code.vegaprotocol.io/protos/vega/wallet/v1"
 	vgrand "code.vegaprotocol.io/shared/libs/rand"
 	"code.vegaprotocol.io/vegawallet/wallet"
 	"code.vegaprotocol.io/vegawallet/wallet/mocks"
@@ -541,6 +543,75 @@ func TestListWalletsSucceeds(t *testing.T) {
 	assert.Equal(t, expectedResp, resp)
 }
 
+func TestSignCommand(t *testing.T) {
+	t.Run("Sign message succeeds", testSignCommandSucceeds)
+	t.Run("Sign message of non-existing wallet fails", testSignCommandWithNonExistingWalletFails)
+}
+
+func testSignCommandSucceeds(t *testing.T) {
+	// given
+	w := importWalletWithKey(t)
+	kp := w.ListKeyPairs()[0]
+
+	req := &wallet.SignCommandRequest{
+		Wallet: w.Name(),
+		Request: &walletpb.SubmitTransactionRequest{
+			PubKey:    kp.PublicKey(),
+			Propagate: false,
+			Command: &walletpb.SubmitTransactionRequest_VoteSubmission{
+				VoteSubmission: &commandspb.VoteSubmission{
+					ProposalId: vgrand.RandomStr(5),
+					Value:      vega.Vote_VALUE_YES,
+				},
+			},
+		},
+		Passphrase: "passphrase",
+	}
+
+	// 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.SignCommand(store, req)
+
+	// then
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	assert.NotEmpty(t, resp.Base64Transaction)
+}
+
+func testSignCommandWithNonExistingWalletFails(t *testing.T) {
+	// given
+	req := &wallet.SignCommandRequest{
+		Wallet: vgrand.RandomStr(5),
+		Request: &walletpb.SubmitTransactionRequest{
+			PubKey:    vgrand.RandomStr(5),
+			Propagate: false,
+			Command: &walletpb.SubmitTransactionRequest_VoteSubmission{
+				VoteSubmission: &commandspb.VoteSubmission{
+					ProposalId: vgrand.RandomStr(5),
+					Value:      vega.Vote_VALUE_YES,
+				},
+			},
+		},
+		Passphrase: "passphrase",
+	}
+
+	// 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.SignCommand(store, req)
+
+	// then
+	require.Error(t, err)
+	assert.Nil(t, resp)
+}
+
 func TestSignMessage(t *testing.T) {
 	t.Run("Sign message succeeds", testSignMessageSucceeds)
 	t.Run("Sign message of non-existing wallet fails", testSignMessageWithNonExistingWalletFails)