Repository: vegaprotocol/vegawallet (go)

Issue:
Add a subcommand to send the previously encoded transaction to the network 
We want probably something like:
```sh
vegawallet rawcommand --raw-payload="0x..."
```

Pull request description:
Introduce `send tx` subcommand
Close #362 

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28bdf95..cd35839 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,12 +7,16 @@
 - [300](https://github.com/vegaprotocol/vegawallet/pull/300) - Trim the `key list` output to only output name and public key
 - [358](https://github.com/vegaprotocol/vegawallet/pull/358) - Rename "human" output to "interactive"
 
+### Deprecation
+- [368](https://github.com/vegaprotocol/vegawallet/pull/368) - Deprecate `command` subcommand in favor of `send command`
+
 ### Improvements
 - [300](https://github.com/vegaprotocol/vegawallet/pull/300) - Internal restructuring of CLI building blocks to improve testability
 - [359](https://github.com/vegaprotocol/vegawallet/pull/359) - Add `key describe` subcommand to allow querying of key information
 - [364](https://github.com/vegaprotocol/vegawallet/pull/364) - Add changelog checker Github action
 - [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
 
 ### 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/command.go b/cmd/command.go
index d1e2c54..8d58a01 100644
--- a/cmd/command.go
+++ b/cmd/command.go
@@ -1,41 +1,21 @@
 package cmd
 
 import (
-	"context"
-	"errors"
 	"fmt"
 	"io"
-	"time"
 
-	api "code.vegaprotocol.io/protos/vega/api/v1"
-	walletpb "code.vegaprotocol.io/protos/vega/wallet/v1"
-	"code.vegaprotocol.io/shared/paths"
 	"code.vegaprotocol.io/vegawallet/cmd/cli"
 	"code.vegaprotocol.io/vegawallet/cmd/flags"
 	"code.vegaprotocol.io/vegawallet/cmd/printer"
-	wcommands "code.vegaprotocol.io/vegawallet/commands"
-	vglog "code.vegaprotocol.io/vegawallet/libs/zap"
-	"code.vegaprotocol.io/vegawallet/network"
-	netstore "code.vegaprotocol.io/vegawallet/network/store/v1"
-	"code.vegaprotocol.io/vegawallet/node"
-	"code.vegaprotocol.io/vegawallet/wallets"
-	"go.uber.org/zap"
 	"go.uber.org/zap/zapcore"
 
-	"github.com/golang/protobuf/jsonpb"
 	"github.com/spf13/cobra"
 )
 
-const (
-	DefaultForwarderRetryCount = 5
-	ForwarderRequestTimeout    = 5 * time.Second
-)
-
 var (
-	ErrNetworkDoesNotHaveGRPCHostConfigured = errors.New("network does not have gRPC hosts configured")
-	ErrDoNotSetPubKeyInRequest              = errors.New("do not set the public key through the request, use --pubkey flag instead")
+	commandLong = cli.LongDesc(`
+		DEPRECATED: use "send command" instead.
 
-	sendCommandLong = cli.LongDesc(`
 		Send a command to a Vega node via the gRPC API. The command can be sent to 
 		any node of a registered network or to a specific node address.
 
@@ -48,36 +28,47 @@ var (
 		'{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}'
 	`)
 
-	sendCommandExample = cli.Examples(`
+	commandExample = cli.Examples(`
 		# Send a command to a registered network
-		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY REQUEST
+		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY COMMAND
 
 		# Send a command to a specific Vega node address
-		vegawallet command --node-address ADDRESS --wallet WALLET --pubkey PUBKEY REQUEST
+		vegawallet command --node-address ADDRESS --wallet WALLET --pubkey PUBKEY COMMAND
 
 		# Send a command with a log level set to debug
-		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY --level debug REQUEST
+		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY --level debug COMMAND
 
 		# Send a command with a maximum of 10 retry
-		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY --retries 10 REQUEST
+		vegawallet command --network NETWORK --wallet WALLET --pubkey PUBKEY --retries 10 COMMAND
 	`)
 )
 
-type SendCommandHandler func(io.Writer, *RootFlags, *SendCommandRequest) error
-
-func NewCmdSendCommand(w io.Writer, rf *RootFlags) *cobra.Command {
-	return BuildCmdSendCommand(w, SendCommand, rf)
+func NewCmdCommand(w io.Writer, rf *RootFlags) *cobra.Command {
+	return BuildCmdCommand(w, SendCommand, rf)
 }
 
-func BuildCmdSendCommand(w io.Writer, handler SendCommandHandler, rf *RootFlags) *cobra.Command {
+func BuildCmdCommand(w io.Writer, handler SendCommandHandler, rf *RootFlags) *cobra.Command {
 	f := &SendCommandFlags{}
 
 	cmd := &cobra.Command{
 		Use:     "command",
 		Short:   "Send a command to a Vega node",
-		Long:    sendCommandLong,
-		Example: sendCommandExample,
-		RunE: func(_ *cobra.Command, _ []string) error {
+		Long:    commandLong,
+		Example: commandExample,
+		RunE: func(_ *cobra.Command, args []string) error {
+			if rf.Output == flags.InteractiveOutput {
+				p := printer.NewInteractivePrinter(w)
+				p.BangMark().DangerText("This command is DEPRECATED.").NextLine()
+				p.BangMark().DangerText("Use `vegawallet send command` instead").NextSection()
+			}
+
+			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
@@ -129,171 +120,3 @@ func BuildCmdSendCommand(w io.Writer, handler SendCommandHandler, rf *RootFlags)
 
 	return cmd
 }
-
-type SendCommandFlags struct {
-	Network        string
-	NodeAddress    string
-	Wallet         string
-	PubKey         string
-	PassphraseFile string
-	Retries        uint64
-	LogLevel       string
-	RawRequest     string
-}
-
-func (f *SendCommandFlags) Validate() (*SendCommandRequest, error) {
-	req := &SendCommandRequest{
-		Retries: f.Retries,
-	}
-
-	if len(f.Wallet) == 0 {
-		return nil, flags.FlagMustBeSpecifiedError("wallet")
-	}
-	req.Wallet = f.Wallet
-
-	if len(f.LogLevel) == 0 {
-		return nil, flags.FlagMustBeSpecifiedError("level")
-	}
-	if err := ValidateLogLevel(f.LogLevel); err != nil {
-		return nil, err
-	}
-	req.LogLevel = f.LogLevel
-
-	if len(f.NodeAddress) == 0 && len(f.Network) == 0 {
-		return nil, flags.OneOfFlagsMustBeSpecifiedError("network", "node-address")
-	}
-	if len(f.NodeAddress) != 0 && len(f.Network) != 0 {
-		return nil, flags.FlagsMutuallyExclusiveError("network", "node-address")
-	}
-	req.NodeAddress = f.NodeAddress
-	req.Network = f.Network
-
-	passphrase, err := flags.GetPassphrase(f.PassphraseFile)
-	if err != nil {
-		return nil, err
-	}
-	req.Passphrase = passphrase
-
-	if len(f.PubKey) == 0 {
-		return nil, flags.FlagMustBeSpecifiedError("pubkey")
-	}
-	if len(f.RawRequest) == 0 {
-		return nil, flags.ArgMustBeSpecifiedError("request")
-	}
-	request := &walletpb.SubmitTransactionRequest{}
-	if err := jsonpb.UnmarshalString(f.RawRequest, request); err != nil {
-		return nil, fmt.Errorf("couldn't unmarshal request: %w", err)
-	}
-	if len(request.PubKey) != 0 {
-		return nil, ErrDoNotSetPubKeyInRequest
-	}
-	request.PubKey = f.PubKey
-	request.Propagate = true
-	req.Request = request
-	errs := wcommands.CheckSubmitTransactionRequest(req.Request)
-	if !errs.Empty() {
-		return nil, fmt.Errorf("invalid request: %w", errs)
-	}
-
-	return req, nil
-}
-
-type SendCommandRequest struct {
-	Network     string
-	NodeAddress string
-	Wallet      string
-	Passphrase  string
-	Retries     uint64
-	LogLevel    string
-	Request     *walletpb.SubmitTransactionRequest
-}
-
-func SendCommand(w io.Writer, rf *RootFlags, req *SendCommandRequest) error {
-	log, err := Build(rf.Output, req.LogLevel)
-	if err != nil {
-		return err
-	}
-	defer vglog.Sync(log)
-
-	var hosts []string
-	if len(req.Network) != 0 {
-		hosts, err = getHostsFromNetwork(rf, req)
-		if err != nil {
-			return err
-		}
-	} else {
-		hosts = []string{req.NodeAddress}
-	}
-
-	forwarder, err := node.NewForwarder(log.Named("forwarder"), network.GRPCConfig{
-		Hosts:   hosts,
-		Retries: req.Retries,
-	})
-	if err != nil {
-		return fmt.Errorf("couldn't initialise the node forwarder: %w", err)
-	}
-	defer func() {
-		// We can ignore this non-blocking error without logging as it's already
-		// logged down stream.
-		_ = forwarder.Stop()
-	}()
-
-	p := printer.NewInteractivePrinter(w)
-	if rf.Output == flags.InteractiveOutput {
-		p.BlueArrow().InfoText("Logs").NextLine()
-	}
-
-	ctx, cancelFn := context.WithTimeout(context.Background(), ForwarderRequestTimeout)
-	defer cancelFn()
-
-	log.Info("retrieving block height")
-	blockHeight, err := forwarder.LastBlockHeight(ctx)
-	if err != nil {
-		return fmt.Errorf("couldn't get last block height: %w", err)
-	}
-	log.Info(fmt.Sprintf("last block height found: %d", blockHeight))
-
-	store, err := wallets.InitialiseStore(rf.Home)
-	if err != nil {
-		return fmt.Errorf("couldn't initialise wallets store: %w", err)
-	}
-	handler := wallets.NewHandler(store)
-	err = handler.LoginWallet(req.Wallet, req.Passphrase)
-	if err != nil {
-		return fmt.Errorf("couldn't login to the wallet %s: %w", req.Wallet, err)
-	}
-	defer handler.LogoutWallet(req.Wallet)
-	tx, err := handler.SignTx(req.Wallet, req.Request, blockHeight)
-	if err != nil {
-		log.Error("couldn't sign transaction", zap.Error(err))
-		return fmt.Errorf("couldn't sign transaction: %w", err)
-	}
-
-	log.Info("transaction successfully signed", zap.String("signature", tx.Signature.Value))
-
-	if err = forwarder.SendTx(ctx, tx, api.SubmitTransactionRequest_TYPE_ASYNC); err != nil {
-		log.Error("couldn't send transaction", zap.Error(err))
-		return fmt.Errorf("couldn't send transaction: %w", err)
-	}
-
-	log.Info("transaction successfully sent")
-
-	return nil
-}
-
-func getHostsFromNetwork(rf *RootFlags, req *SendCommandRequest) ([]string, error) {
-	netStore, err := netstore.InitialiseStore(paths.New(rf.Home))
-	if err != nil {
-		return nil, fmt.Errorf("couldn't initialise network store: %w", err)
-	}
-	net, err := network.GetNetwork(netStore, req.Network)
-	if err != nil {
-		return nil, err
-	}
-
-	if len(net.API.GRPC.Hosts) == 0 {
-		return nil, ErrNetworkDoesNotHaveGRPCHostConfigured
-	}
-
-	return net.API.GRPC.Hosts, nil
-}
diff --git a/cmd/flags/errors.go b/cmd/flags/errors.go
index bd34754..a96f2a5 100644
--- a/cmd/flags/errors.go
+++ b/cmd/flags/errors.go
@@ -2,6 +2,7 @@ package flags
 
 import (
 	"fmt"
+	"strings"
 )
 
 type FlagError struct {
@@ -30,6 +31,12 @@ func ArgMustBeSpecifiedError(name string) error {
 	}
 }
 
+func TooManyArgsError(names ...string) error {
+	return FlagError{
+		message: fmt.Sprintf("too many arguments specified, only expect: %v", strings.Join(names, ", ")),
+	}
+}
+
 func OneOfFlagsMustBeSpecifiedError(n1, n2 string) error {
 	return FlagError{
 		message: fmt.Sprintf("--%s or --%s flags must be specified", n1, n2),
diff --git a/cmd/root.go b/cmd/root.go
index 12777d8..97cccf2 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -102,7 +102,7 @@ func BuildCmdRoot(w io.Writer, vh CheckVersionHandler) *cobra.Command {
 	)
 
 	// Root commands
-	cmd.AddCommand(NewCmdSendCommand(w, f))
+	cmd.AddCommand(NewCmdCommand(w, f))
 	cmd.AddCommand(NewCmdInit(w, f))
 	cmd.AddCommand(NewCmdSignMessage(w, f))
 	cmd.AddCommand(NewCmdVerifyMessage(w, f))
@@ -111,6 +111,7 @@ func BuildCmdRoot(w io.Writer, vh CheckVersionHandler) *cobra.Command {
 	// Sub-commands
 	cmd.AddCommand(NewCmdKey(w, f))
 	cmd.AddCommand(NewCmdNetwork(w, f))
+	cmd.AddCommand(NewCmdSend(w, f))
 	cmd.AddCommand(NewCmdService(w, f))
 
 	// Wallet commands
diff --git a/cmd/send.go b/cmd/send.go
new file mode 100644
index 0000000..55c035f
--- /dev/null
+++ b/cmd/send.go
@@ -0,0 +1,50 @@
+package cmd
+
+import (
+	"errors"
+	"fmt"
+	"io"
+	"time"
+
+	"code.vegaprotocol.io/shared/paths"
+	"code.vegaprotocol.io/vegawallet/network"
+	netstore "code.vegaprotocol.io/vegawallet/network/store/v1"
+	"github.com/spf13/cobra"
+)
+
+const (
+	DefaultForwarderRetryCount = 5
+	ForwarderRequestTimeout    = 5 * time.Second
+)
+
+var ErrNetworkDoesNotHaveGRPCHostConfigured = errors.New("network does not have gRPC hosts configured")
+
+func NewCmdSend(w io.Writer, rf *RootFlags) *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "send",
+		Short: "Send data to the Vega network",
+		Long:  "Send data to the Vega network",
+	}
+
+	// create subcommands
+	cmd.AddCommand(NewCmdSendCommand(w, rf))
+	cmd.AddCommand(NewCmdSendTx(w, rf))
+	return cmd
+}
+
+func getHostsFromNetwork(rf *RootFlags, networkName string) ([]string, error) {
+	netStore, err := netstore.InitialiseStore(paths.New(rf.Home))
+	if err != nil {
+		return nil, fmt.Errorf("couldn't initialise network store: %w", err)
+	}
+	net, err := network.GetNetwork(netStore, networkName)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(net.API.GRPC.Hosts) == 0 {
+		return nil, ErrNetworkDoesNotHaveGRPCHostConfigured
+	}
+
+	return net.API.GRPC.Hosts, nil
+}
diff --git a/cmd/send_command.go b/cmd/send_command.go
new file mode 100644
index 0000000..13d21d3
--- /dev/null
+++ b/cmd/send_command.go
@@ -0,0 +1,281 @@
+package cmd
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+
+	api "code.vegaprotocol.io/protos/vega/api/v1"
+	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"
+	vglog "code.vegaprotocol.io/vegawallet/libs/zap"
+	"code.vegaprotocol.io/vegawallet/network"
+	"code.vegaprotocol.io/vegawallet/node"
+	"code.vegaprotocol.io/vegawallet/wallets"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+
+	"github.com/golang/protobuf/jsonpb"
+	"github.com/spf13/cobra"
+)
+
+var (
+	ErrDoNotSetPubKeyInCommand = errors.New("do not set the public key through the command, use --pubkey flag instead")
+
+	sendCommandLong = cli.LongDesc(`
+		Send a command to a Vega node via the gRPC API. The command can be sent to 
+		any node of a registered network or to a specific node address.
+
+		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"}}'
+	`)
+
+	sendCommandExample = cli.Examples(`
+		# Send a command to a registered network
+		vegawallet send command --network NETWORK --wallet WALLET --pubkey PUBKEY COMMAND
+
+		# Send a command to a specific Vega node address
+		vegawallet send command --node-address ADDRESS --wallet WALLET --pubkey PUBKEY COMMAND
+
+		# Send a command with a log level set to debug
+		vegawallet send command --network NETWORK --wallet WALLET --pubkey PUBKEY --level debug COMMAND
+
+		# Send a command with a maximum of 10 retry
+		vegawallet send command --network NETWORK --wallet WALLET --pubkey PUBKEY --retries 10 COMMAND
+	`)
+)
+
+type SendCommandHandler func(io.Writer, *RootFlags, *SendCommandRequest) error
+
+func NewCmdSendCommand(w io.Writer, rf *RootFlags) *cobra.Command {
+	return BuildCmdSendCommand(w, SendCommand, rf)
+}
+
+func BuildCmdSendCommand(w io.Writer, handler SendCommandHandler, rf *RootFlags) *cobra.Command {
+	f := &SendCommandFlags{}
+
+	cmd := &cobra.Command{
+		Use:     "command",
+		Short:   "Send a command to a Vega node",
+		Long:    sendCommandLong,
+		Example: sendCommandExample,
+		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
+			}
+
+			if err := handler(w, rf, req); err != nil {
+				return err
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVarP(&f.Network,
+		"network", "n",
+		"",
+		"Network to which the command is sent",
+	)
+	cmd.Flags().StringVar(&f.NodeAddress,
+		"node-address",
+		"",
+		"Vega node address to which the command is sent",
+	)
+	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().StringVar(&f.LogLevel,
+		"level",
+		zapcore.InfoLevel.String(),
+		fmt.Sprintf("Set the log level: %v", SupportedLogLevels),
+	)
+	cmd.Flags().Uint64Var(&f.Retries,
+		"retries",
+		DefaultForwarderRetryCount,
+		"Number of retries when contacting the Vega node",
+	)
+
+	return cmd
+}
+
+type SendCommandFlags struct {
+	Network        string
+	NodeAddress    string
+	Wallet         string
+	PubKey         string
+	PassphraseFile string
+	Retries        uint64
+	LogLevel       string
+	RawCommand     string
+}
+
+func (f *SendCommandFlags) Validate() (*SendCommandRequest, error) {
+	req := &SendCommandRequest{
+		Retries: f.Retries,
+	}
+
+	if len(f.Wallet) == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("wallet")
+	}
+	req.Wallet = f.Wallet
+
+	if len(f.LogLevel) == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("level")
+	}
+	if err := ValidateLogLevel(f.LogLevel); err != nil {
+		return nil, err
+	}
+	req.LogLevel = f.LogLevel
+
+	if len(f.NodeAddress) == 0 && len(f.Network) == 0 {
+		return nil, flags.OneOfFlagsMustBeSpecifiedError("network", "node-address")
+	}
+	if len(f.NodeAddress) != 0 && len(f.Network) != 0 {
+		return nil, flags.FlagsMutuallyExclusiveError("network", "node-address")
+	}
+	req.NodeAddress = f.NodeAddress
+	req.Network = f.Network
+
+	passphrase, err := flags.GetPassphrase(f.PassphraseFile)
+	if err != nil {
+		return nil, err
+	}
+	req.Passphrase = passphrase
+
+	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
+}
+
+type SendCommandRequest struct {
+	Network     string
+	NodeAddress string
+	Wallet      string
+	Passphrase  string
+	Retries     uint64
+	LogLevel    string
+	Request     *walletpb.SubmitTransactionRequest
+}
+
+func SendCommand(w io.Writer, rf *RootFlags, req *SendCommandRequest) error {
+	log, err := Build(rf.Output, req.LogLevel)
+	if err != nil {
+		return err
+	}
+	defer vglog.Sync(log)
+
+	// Login early to check passphrase before running any query
+	store, err := wallets.InitialiseStore(rf.Home)
+	if err != nil {
+		return fmt.Errorf("couldn't initialise wallets store: %w", err)
+	}
+	handler := wallets.NewHandler(store)
+	err = handler.LoginWallet(req.Wallet, req.Passphrase)
+	if err != nil {
+		return fmt.Errorf("couldn't login to the wallet %s: %w", req.Wallet, err)
+	}
+	defer handler.LogoutWallet(req.Wallet)
+
+	var hosts []string
+	if len(req.Network) != 0 {
+		hosts, err = getHostsFromNetwork(rf, req.Network)
+		if err != nil {
+			return err
+		}
+	} else {
+		hosts = []string{req.NodeAddress}
+	}
+
+	forwarder, err := node.NewForwarder(log.Named("forwarder"), network.GRPCConfig{
+		Hosts:   hosts,
+		Retries: req.Retries,
+	})
+	if err != nil {
+		return fmt.Errorf("couldn't initialise the node forwarder: %w", err)
+	}
+	defer func() {
+		if err = forwarder.Stop(); err != nil {
+			log.Warn("couldn't stop the forwarder", zap.Error(err))
+		}
+	}()
+
+	p := printer.NewInteractivePrinter(w)
+	if rf.Output == flags.InteractiveOutput {
+		p.BlueArrow().InfoText("Logs").NextLine()
+	}
+
+	ctx, cancelFn := context.WithTimeout(context.Background(), ForwarderRequestTimeout)
+	defer cancelFn()
+
+	log.Info("retrieving block height")
+	blockHeight, err := forwarder.LastBlockHeight(ctx)
+	if err != nil {
+		return fmt.Errorf("couldn't get last block height: %w", err)
+	}
+	log.Info(fmt.Sprintf("last block height found: %d", blockHeight))
+
+	tx, err := handler.SignTx(req.Wallet, req.Request, blockHeight)
+	if err != nil {
+		log.Error("couldn't sign transaction", zap.Error(err))
+		return fmt.Errorf("couldn't sign transaction: %w", err)
+	}
+
+	log.Info("transaction successfully signed", zap.String("signature", tx.Signature.Value))
+
+	if err = forwarder.SendTx(ctx, tx, api.SubmitTransactionRequest_TYPE_ASYNC); err != nil {
+		log.Error("couldn't send transaction", zap.Error(err))
+		return fmt.Errorf("couldn't send transaction: %w", err)
+	}
+
+	log.Info("transaction successfully sent")
+
+	return nil
+}
diff --git a/cmd/send_tx.go b/cmd/send_tx.go
new file mode 100644
index 0000000..6fd6053
--- /dev/null
+++ b/cmd/send_tx.go
@@ -0,0 +1,206 @@
+package cmd
+
+import (
+	"context"
+	"encoding/base64"
+	"fmt"
+	"io"
+
+	api "code.vegaprotocol.io/protos/vega/api/v1"
+	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	"code.vegaprotocol.io/vegawallet/cmd/cli"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"code.vegaprotocol.io/vegawallet/cmd/printer"
+	vglog "code.vegaprotocol.io/vegawallet/libs/zap"
+	"code.vegaprotocol.io/vegawallet/network"
+	"code.vegaprotocol.io/vegawallet/node"
+	"github.com/golang/protobuf/proto"
+	"go.uber.org/zap"
+	"go.uber.org/zap/zapcore"
+
+	"github.com/spf13/cobra"
+)
+
+var (
+	sendTxLong = cli.LongDesc(`
+		Send a transaction to a Vega node via the gRPC API. The command can be sent to 
+		any node of a registered network or to a specific node address.
+
+		The transaction is base64-encoded.
+	`)
+
+	sendTxExample = cli.Examples(`
+		# Send a command to a registered network
+		vegawallet send tx --network NETWORK BASE64_TRANSACTION
+
+		# Send a command to a specific Vega node address
+		vegawallet send tx --node-address ADDRESS BASE64_TRANSACTION
+
+		# Send a command with a log level set to debug
+		vegawallet send tx --network NETWORK --level debug BASE64_TRANSACTION
+
+		# Send a command with a maximum of 10 retry
+		vegawallet send tx --network NETWORK --retries 10 BASE64_TRANSACTION
+	`)
+)
+
+type SendTxHandler func(io.Writer, *RootFlags, *SendTxRequest) error
+
+func NewCmdSendTx(w io.Writer, rf *RootFlags) *cobra.Command {
+	return BuildCmdSendTx(w, SendTx, rf)
+}
+
+func BuildCmdSendTx(w io.Writer, handler SendTxHandler, rf *RootFlags) *cobra.Command {
+	f := &SendTxFlags{}
+
+	cmd := &cobra.Command{
+		Use:     "command",
+		Short:   "Send a transaction to a Vega node",
+		Long:    sendTxLong,
+		Example: sendTxExample,
+		RunE: func(_ *cobra.Command, args []string) error {
+			if aLen := len(args); aLen == 0 {
+				return flags.ArgMustBeSpecifiedError("transaction")
+			} else if aLen > 1 {
+				return flags.TooManyArgsError("transaction")
+			}
+			f.RawTx = args[0]
+
+			req, err := f.Validate()
+			if err != nil {
+				return err
+			}
+
+			if err := handler(w, rf, req); err != nil {
+				return err
+			}
+
+			return nil
+		},
+	}
+
+	cmd.Flags().StringVarP(&f.Network,
+		"network", "n",
+		"",
+		"Network to which the command is sent",
+	)
+	cmd.Flags().StringVar(&f.NodeAddress,
+		"node-address",
+		"",
+		"Vega node address to which the command is sent",
+	)
+	cmd.Flags().StringVar(&f.LogLevel,
+		"level",
+		zapcore.InfoLevel.String(),
+		fmt.Sprintf("Set the log level: %v", SupportedLogLevels),
+	)
+	cmd.Flags().Uint64Var(&f.Retries,
+		"retries",
+		DefaultForwarderRetryCount,
+		"Number of retries when contacting the Vega node",
+	)
+
+	return cmd
+}
+
+type SendTxFlags struct {
+	Network     string
+	NodeAddress string
+	Retries     uint64
+	LogLevel    string
+	RawTx       string
+}
+
+func (f *SendTxFlags) Validate() (*SendTxRequest, error) {
+	req := &SendTxRequest{
+		Retries: f.Retries,
+	}
+
+	if len(f.LogLevel) == 0 {
+		return nil, flags.FlagMustBeSpecifiedError("level")
+	}
+	if err := ValidateLogLevel(f.LogLevel); err != nil {
+		return nil, err
+	}
+	req.LogLevel = f.LogLevel
+
+	if len(f.NodeAddress) == 0 && len(f.Network) == 0 {
+		return nil, flags.OneOfFlagsMustBeSpecifiedError("network", "node-address")
+	}
+	if len(f.NodeAddress) != 0 && len(f.Network) != 0 {
+		return nil, flags.FlagsMutuallyExclusiveError("network", "node-address")
+	}
+	req.NodeAddress = f.NodeAddress
+	req.Network = f.Network
+
+	if len(f.RawTx) == 0 {
+		return nil, flags.ArgMustBeSpecifiedError("transaction")
+	}
+	decodedTx, err := base64.StdEncoding.DecodeString(f.RawTx)
+	if err != nil {
+		return nil, flags.MustBase64EncodedError("transaction")
+	}
+	tx := &commandspb.Transaction{}
+	if err := proto.Unmarshal(decodedTx, tx); err != nil {
+		return nil, fmt.Errorf("couldn't unmarshal transaction: %w", err)
+	}
+	req.Tx = tx
+
+	return req, nil
+}
+
+type SendTxRequest struct {
+	Network     string
+	NodeAddress string
+	Retries     uint64
+	LogLevel    string
+	Tx          *commandspb.Transaction
+}
+
+func SendTx(w io.Writer, rf *RootFlags, req *SendTxRequest) error {
+	log, err := Build(rf.Output, req.LogLevel)
+	if err != nil {
+		return err
+	}
+	defer vglog.Sync(log)
+
+	var hosts []string
+	if len(req.Network) != 0 {
+		hosts, err = getHostsFromNetwork(rf, req.Network)
+		if err != nil {
+			return err
+		}
+	} else {
+		hosts = []string{req.NodeAddress}
+	}
+
+	forwarder, err := node.NewForwarder(log.Named("forwarder"), network.GRPCConfig{
+		Hosts:   hosts,
+		Retries: req.Retries,
+	})
+	if err != nil {
+		return fmt.Errorf("couldn't initialise the node forwarder: %w", err)
+	}
+	defer func() {
+		if err = forwarder.Stop(); err != nil {
+			log.Warn("couldn't stop the forwarder", zap.Error(err))
+		}
+	}()
+
+	p := printer.NewInteractivePrinter(w)
+	if rf.Output == flags.InteractiveOutput {
+		p.BlueArrow().InfoText("Logs").NextLine()
+	}
+
+	ctx, cancelFn := context.WithTimeout(context.Background(), ForwarderRequestTimeout)
+	defer cancelFn()
+
+	if err = forwarder.SendTx(ctx, req.Tx, api.SubmitTransactionRequest_TYPE_ASYNC); err != nil {
+		log.Error("couldn't send transaction", zap.Error(err))
+		return fmt.Errorf("couldn't send transaction: %w", err)
+	}
+
+	log.Info("transaction successfully sent")
+
+	return nil
+}


Test patch:
diff --git a/cmd/command_test.go b/cmd/send_command_test.go
similarity index 93%
rename from cmd/command_test.go
rename to cmd/send_command_test.go
index 364764a..e8d1399 100644
--- a/cmd/command_test.go
+++ b/cmd/send_command_test.go
@@ -45,7 +45,7 @@ func testSendCommandFlagsValidFlagsSucceeds(t *testing.T) {
 		Retries:        10,
 		LogLevel:       "debug",
 		PassphraseFile: passphraseFilePath,
-		RawRequest:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+		RawCommand:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
 	}
 
 	expectedReq := &cmd.SendCommandRequest{
@@ -180,13 +180,13 @@ func testSendCommandFlagsMissingRequestFails(t *testing.T) {
 
 	// given
 	f := newSendCommandFlags(t, testDir)
-	f.RawRequest = ""
+	f.RawCommand = ""
 
 	// when
 	req, err := f.Validate()
 
 	// then
-	assert.ErrorIs(t, err, flags.ArgMustBeSpecifiedError("request"))
+	assert.ErrorIs(t, err, flags.ArgMustBeSpecifiedError("command"))
 	assert.Nil(t, req)
 }
 
@@ -196,7 +196,7 @@ func testSendCommandFlagsMalformedRequestFails(t *testing.T) {
 
 	// given
 	f := newSendCommandFlags(t, testDir)
-	f.RawRequest = `<request>Not gonna support that</request>`
+	f.RawCommand = `<request>Not gonna support that</request>`
 
 	// when
 	req, err := f.Validate()
@@ -212,7 +212,7 @@ func testSendCommandFlagsInvalidRequestFails(t *testing.T) {
 
 	// given
 	f := newSendCommandFlags(t, testDir)
-	f.RawRequest = `{"voteSubmission": {}}`
+	f.RawCommand = `{"voteSubmission": {}}`
 
 	// when
 	req, err := f.Validate()
@@ -228,13 +228,13 @@ func testSendCommandFlagsRequestWithPubKeyFails(t *testing.T) {
 
 	// given
 	f := newSendCommandFlags(t, testDir)
-	f.RawRequest = `{"pubKey": "qwerty123456", "voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`
+	f.RawCommand = `{"pubKey": "qwerty123456", "voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`
 
 	// when
 	req, err := f.Validate()
 
 	// then
-	assert.ErrorIs(t, err, cmd.ErrDoNotSetPubKeyInRequest)
+	assert.ErrorIs(t, err, cmd.ErrDoNotSetPubKeyInCommand)
 	assert.Nil(t, req)
 }
 
@@ -251,7 +251,7 @@ func newSendCommandFlags(t *testing.T, testDir string) *cmd.SendCommandFlags {
 		NodeAddress:    "",
 		Retries:        10,
 		LogLevel:       "debug",
-		RawRequest:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
+		RawCommand:     `{"voteSubmission": {"proposalId": "some-id", "value": "VALUE_YES"}}`,
 		Wallet:         walletName,
 		PubKey:         pubKey,
 		PassphraseFile: passphraseFilePath,
diff --git a/cmd/send_tx_test.go b/cmd/send_tx_test.go
new file mode 100644
index 0000000..f1b6432
--- /dev/null
+++ b/cmd/send_tx_test.go
@@ -0,0 +1,156 @@
+package cmd_test
+
+import (
+	"testing"
+
+	commandspb "code.vegaprotocol.io/protos/vega/commands/v1"
+	vgrand "code.vegaprotocol.io/shared/libs/rand"
+	"code.vegaprotocol.io/vegawallet/cmd"
+	"code.vegaprotocol.io/vegawallet/cmd/flags"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestSendTxFlags(t *testing.T) {
+	t.Run("Valid flags succeeds", testSendTxFlagsValidFlagsSucceeds)
+	t.Run("Missing log level fails", testSendTxFlagsMissingLogLevelFails)
+	t.Run("Unsupported log level fails", testSendTxFlagsUnsupportedLogLevelFails)
+	t.Run("Missing network and node address fails", testSendTxFlagsMissingNetworkAndNodeAddressFails)
+	t.Run("Both network and node address specified fails", testSendTxFlagsBothNetworkAndNodeAddressSpecifiedFails)
+	t.Run("Missing tx fails", testSendTxFlagsMissingTxFails)
+	t.Run("Malformed tx fails", testSendTxFlagsMalformedTxFails)
+}
+
+func testSendTxFlagsValidFlagsSucceeds(t *testing.T) {
+	// given
+	network := vgrand.RandomStr(10)
+
+	f := &cmd.SendTxFlags{
+		Network:     network,
+		NodeAddress: "",
+		Retries:     10,
+		LogLevel:    "debug",
+		RawTx:       "ChwIxZXB58qn4K06EMC2BPI+CwoHc29tZS1pZBACEpMBCoABMTM1ZDdmN2Q4MjhkMjg3ZDMyNDQzYjQ2NGEyZDQwNTkyZjQ1OTgwMGQ0MGZmMzY5Y2VhMGFkZDUzZmZjNjYzYzlkZmU2YTI4MGIxZWI4MjdiOTJmYmY2NTY3NzI3MjgwYzMwODBiNjg5NGYyMjYzZmJlYmFkN2I2M2VhN2M4MGYSDHZlZ2EvZWQyNTUxORgBgH0B0j5AZjM4MTc5NjljZDMxNmQ1NmMzN2EzYzE5MjVjMDMyOWM5ZTMxMDQ0ODI5OGZmNzYyMjMwMTVjN2QyY2RiOTFiOQ==",
+	}
+
+	expectedReq := &cmd.SendTxRequest{
+		Network:     network,
+		NodeAddress: "",
+		Retries:     10,
+		LogLevel:    "debug",
+		Tx: &commandspb.Transaction{
+			InputData: []uint8{8, 197, 149, 193, 231, 202, 167, 224, 173, 58, 16, 192, 182, 4, 242, 62, 11, 10, 7, 115, 111, 109, 101, 45, 105, 100, 16, 2},
+			Signature: &commandspb.Signature{
+				Value:   "135d7f7d828d287d32443b464a2d40592f459800d40ff369cea0add53ffc663c9dfe6a280b1eb827b92fbf6567727280c3080b6894f2263fbebad7b63ea7c80f",
+				Algo:    "vega/ed25519",
+				Version: 1,
+			},
+			From: &commandspb.Transaction_PubKey{
+				PubKey: "f3817969cd316d56c37a3c1925c0329c9e310448298ff76223015c7d2cdb91b9",
+			},
+			Version: 1,
+		},
+	}
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	require.NoError(t, err)
+	require.NotNil(t, req)
+	assert.Equal(t, expectedReq, req)
+}
+
+func testSendTxFlagsMissingLogLevelFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.LogLevel = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagMustBeSpecifiedError("level"))
+	assert.Nil(t, req)
+}
+
+func testSendTxFlagsUnsupportedLogLevelFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.LogLevel = "super-critical"
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, cmd.NewUnsupportedFlagValueError("super-critical"))
+	assert.Nil(t, req)
+}
+
+func testSendTxFlagsMissingNetworkAndNodeAddressFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.Network = ""
+	f.NodeAddress = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.OneOfFlagsMustBeSpecifiedError("network", "node-address"))
+	assert.Nil(t, req)
+}
+
+func testSendTxFlagsBothNetworkAndNodeAddressSpecifiedFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.Network = vgrand.RandomStr(10)
+	f.NodeAddress = vgrand.RandomStr(10)
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.FlagsMutuallyExclusiveError("network", "node-address"))
+	assert.Nil(t, req)
+}
+
+func testSendTxFlagsMissingTxFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.RawTx = ""
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.ErrorIs(t, err, flags.ArgMustBeSpecifiedError("transaction"))
+	assert.Nil(t, req)
+}
+
+func testSendTxFlagsMalformedTxFails(t *testing.T) {
+	// given
+	f := newSendTxFlags(t)
+	f.RawTx = `<request>Not gonna support that</request>`
+
+	// when
+	req, err := f.Validate()
+
+	// then
+	assert.Error(t, err)
+	assert.Nil(t, req)
+}
+
+func newSendTxFlags(t *testing.T) *cmd.SendTxFlags {
+	t.Helper()
+
+	networkName := vgrand.RandomStr(10)
+
+	return &cmd.SendTxFlags{
+		Network:     networkName,
+		NodeAddress: "",
+		Retries:     10,
+		LogLevel:    "debug",
+		RawTx:       "ChsItbycz7nhsO4/EPZ38j4LCgdzb21lLWlkEAISkwEKgAE4NjNjY2NhZGU5OTM5NTU5NWFmMmRkYjc4MTRiM2Q0NTE4NTllNDljNGRkZjUwYjRkZTJkOGUwNTBhY2U2YTQzOTM4OGJmMmFiN2E0N2NhZDM3MjQ3YWEwNzU1Yzk5NmMxZDJmMDY4MTI1YzY5NGVlNGNiMmU4ZWEyZmE2YmYwNRIMdmVnYS9lZDI1NTE5GAGAfQHSPkBmMzgxNzk2OWNkMzE2ZDU2YzM3YTNjMTkyNWMwMzI5YzllMzEwNDQ4Mjk4ZmY3NjIyMzAxNWM3ZDJjZGI5MWI5",
+	}
+}