Repository: vegaprotocol/vegawallet (go)

Issue:
Add and endpoint to the wallet-service to check transactions
Now that a core node supports [CheckTransaction](https://github.com/vegaprotocol/protos/blob/develop/sources/vega/api/v1/core.proto#L27) we should add an equivalent endpoint to the vega wallet service, `/api/v1/command/check` for example. 

This will pass the check-request onto core and will enable a nice workflow where people can do things like:
```
req = "some transaction"
url = f"{wallet_server_url}/api/v1/command/check"
response = requests.post(url, headers=headers, json=req)
assert response.ok

# send it for real now we now its ok
url = f"{wallet_server_url}/api/v1/command/sync"
response = requests.post(url, headers=headers, json=req)
```

Pull request description:
Feat/add check tx endpoint
Close #524 

Patch:
diff --git a/cmd/service_run.go b/cmd/service_run.go
index a382c3b..84ab1c6 100644
--- a/cmd/service_run.go
+++ b/cmd/service_run.go
@@ -398,6 +398,7 @@ func waitSig(ctx context.Context, cfunc func(), log *zap.Logger, pendingSigReque
 				p.CheckMark().SuccessText("Transaction approved").NextLine()
 
 				sentTx := <-sentTxs
+				log.Info("transaction sent", zap.Any("ID", sentTx.TxID), zap.Any("hash", sentTx.TxHash))
 				if sentTx.Error != nil {
 					log.Error("transaction failed", zap.Any("transaction", txStr))
 					p.BangMark().DangerText("Transaction failed! ").NextLine()
diff --git a/node/forwarder.go b/node/forwarder.go
index 9a4bde4..8ff86da 100644
--- a/node/forwarder.go
+++ b/node/forwarder.go
@@ -108,6 +108,34 @@ func (n *Forwarder) LastBlockHeightAndHash(ctx context.Context) (*api.LastBlockH
 	return resp, clt, err
 }
 
+func (n *Forwarder) CheckTx(ctx context.Context, tx *commandspb.Transaction, cltIdx int) (*api.CheckTransactionResponse, error) {
+	req := api.CheckTransactionRequest{
+		Tx: tx,
+	}
+	var resp *api.CheckTransactionResponse
+	if cltIdx < 0 {
+		cltIdx = n.nextClt()
+	}
+	err := backoff.Retry(
+		func() error {
+			clt := n.clts[cltIdx]
+			r, err := clt.CheckTransaction(ctx, &req)
+			if err != nil {
+				n.log.Error("Couldn't check transaction", zap.Error(err))
+				return err
+			}
+			n.log.Debug("Response from CheckTransaction",
+				zap.Bool("success", r.Success),
+			)
+			resp = r
+			return nil
+		},
+		backoff.WithMaxRetries(backoff.NewExponentialBackOff(), n.nodeCfgs.Retries),
+	)
+
+	return resp, err
+}
+
 func (n *Forwarder) SendTx(ctx context.Context, tx *commandspb.Transaction, ty api.SubmitTransactionRequest_Type, cltIdx int) (string, error) {
 	req := api.SubmitTransactionRequest{
 		Tx:   tx,
diff --git a/service/mocks/node_forward_mock.go b/service/mocks/node_forward_mock.go
index 618f65a..92cf43c 100644
--- a/service/mocks/node_forward_mock.go
+++ b/service/mocks/node_forward_mock.go
@@ -66,6 +66,15 @@ func (mr *MockNodeForwardMockRecorder) LastBlockHeightAndHash(arg0 interface{})
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LastBlockHeightAndHash", reflect.TypeOf((*MockNodeForward)(nil).LastBlockHeightAndHash), arg0)
 }
 
+// CheckTx mocks base method.
+func (m *MockNodeForward) CheckTx(arg0 context.Context, arg1 *v10.Transaction, arg2 int) (*v1.CheckTransactionResponse, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "CheckTx", arg0, arg1, arg2)
+	ret0, _ := ret[0].(*v1.CheckTransactionResponse)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
 // SendTx mocks base method.
 func (m *MockNodeForward) SendTx(arg0 context.Context, arg1 *v10.Transaction, arg2 v1.SubmitTransactionRequest_Type, arg3 int) (string, error) {
 	m.ctrl.T.Helper()
@@ -75,6 +84,12 @@ func (m *MockNodeForward) SendTx(arg0 context.Context, arg1 *v10.Transaction, ar
 	return ret0, ret1
 }
 
+// CheckTx indicates an expected call of CheckTx.
+func (mr *MockNodeForwardMockRecorder) CheckTx(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckTx", reflect.TypeOf((*MockNodeForward)(nil).CheckTx), arg0, arg1, arg2)
+}
+
 // SendTx indicates an expected call of SendTx.
 func (mr *MockNodeForwardMockRecorder) SendTx(arg0, arg1, arg2, arg3 interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
diff --git a/service/mocks/wallet_handler_mock.go b/service/mocks/wallet_handler_mock.go
index d2699c2..f653510 100644
--- a/service/mocks/wallet_handler_mock.go
+++ b/service/mocks/wallet_handler_mock.go
@@ -159,6 +159,12 @@ func (m *MockWalletHandler) SignTx(arg0 string, arg1 *v10.SubmitTransactionReque
 	return ret0, ret1
 }
 
+// SignTx indicates an expected call of CheckTx
+func (mr *MockWalletHandlerMockRecorder) CheckTx(arg0, arg1, arg2 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckTx", reflect.TypeOf((*MockWalletHandler)(nil).SignTx), arg0, arg1, arg2)
+}
+
 // SignTx indicates an expected call of SignTx
 func (mr *MockWalletHandlerMockRecorder) SignTx(arg0, arg1, arg2 interface{}) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
diff --git a/service/service.go b/service/service.go
index ac52344..08ddb6f 100644
--- a/service/service.go
+++ b/service/service.go
@@ -414,6 +414,7 @@ type Auth interface {
 //go:generate go run github.com/golang/mock/mockgen -destination mocks/node_forward_mock.go -package mocks code.vegaprotocol.io/vegawallet/service NodeForward
 type NodeForward interface {
 	SendTx(context.Context, *commandspb.Transaction, api.SubmitTransactionRequest_Type, int) (string, error)
+	CheckTx(context.Context, *commandspb.Transaction, int) (*api.CheckTransactionResponse, error)
 	HealthCheck(context.Context) error
 	LastBlockHeightAndHash(context.Context) (*api.LastBlockHeightResponse, int, error)
 }
@@ -450,6 +451,7 @@ func NewService(log *zap.Logger, net *network.Network, h WalletHandler, a Auth,
 
 	s.handle(http.MethodPost, "/api/v1/command", extractToken(s.SignTx))
 	s.handle(http.MethodPost, "/api/v1/command/sync", extractToken(s.SignTxSync))
+	s.handle(http.MethodPost, "/api/v1/command/check", extractToken(s.CheckTx))
 	s.handle(http.MethodPost, "/api/v1/command/commit", extractToken(s.SignTxCommit))
 	s.handle(http.MethodPost, "/api/v1/sign", extractToken(s.SignAny))
 	s.handle(http.MethodPost, "/api/v1/verify", s.VerifyAny)
@@ -714,6 +716,79 @@ func (s *Service) VerifyAny(w http.ResponseWriter, r *http.Request, _ httprouter
 	s.writeSuccess(w, VerifyAnyResponse{Valid: verified})
 }
 
+func (s *Service) CheckTx(token string, w http.ResponseWriter, r *http.Request, p httprouter.Params) {
+	defer r.Body.Close()
+
+	name, err := s.auth.VerifyToken(token)
+	if err != nil {
+		s.writeForbiddenError(w, err)
+		return
+	}
+
+	req, errs := ParseSubmitTransactionRequest(r)
+	if !errs.Empty() {
+		s.writeBadRequest(w, errs)
+		return
+	}
+
+	blockData, cltIdx, err := s.nodeForward.LastBlockHeightAndHash(r.Context())
+	if err != nil {
+		s.writeInternalError(w, ErrCouldNotGetBlockHeight)
+		return
+	}
+
+	tx, err := s.handler.SignTx(name, req, blockData.Height)
+	if err != nil {
+		s.writeInternalError(w, err)
+		return
+	}
+
+	// generate proof of work for the transaction
+	tid := vgcrypto.RandomHash()
+	powNonce, _, err := vgcrypto.PoW(blockData.Hash, tid, uint(blockData.SpamPowDifficulty), vgcrypto.Sha3)
+	if err != nil {
+		s.writeInternalError(w, err)
+		return
+	}
+	tx.Pow = &commandspb.ProofOfWork{
+		Tid:   tid,
+		Nonce: powNonce,
+	}
+
+	result, err := s.nodeForward.CheckTx(r.Context(), tx, cltIdx)
+	if err != nil {
+		if st, ok := status.FromError(err); ok {
+			var details []string
+			for _, v := range st.Details() {
+				v, ok := v.(*typespb.ErrorDetail)
+				if !ok {
+					s.writeError(w, newErrorResponse(fmt.Sprintf("couldn't cast status details to error details: %v", v)), http.StatusInternalServerError)
+				}
+				details = append(details, v.Message)
+			}
+
+			s.writeError(w, newErrorWithDetails(err.Error(), details), http.StatusInternalServerError)
+		} else {
+			s.writeInternalError(w, err)
+		}
+		return
+	}
+
+	s.writeSuccess(w, struct {
+		Success   bool                    `json:"success"`
+		Code      uint32                  `json:"code"`
+		GasWanted int64                   `json:"gas_wanted"`
+		GasUsed   int64                   `json:"gas_used"`
+		Tx        *commandspb.Transaction `json:"tx"`
+	}{
+		Success:   result.Success,
+		Code:      result.Code,
+		GasWanted: result.GasWanted,
+		GasUsed:   result.GasUsed,
+		Tx:        tx,
+	})
+}
+
 func (s *Service) SignTxSync(token string, w http.ResponseWriter, r *http.Request, p httprouter.Params) {
 	s.signTx(token, w, r, p, api.SubmitTransactionRequest_TYPE_SYNC)
 }


Test patch:
diff --git a/service/service_test.go b/service/service_test.go
index f0e2224..fcf03b4 100644
--- a/service/service_test.go
+++ b/service/service_test.go
@@ -101,6 +101,9 @@ func TestService(t *testing.T) {
 	t.Run("update metadata invalid request", testServiceUpdateMetaFailInvalidRequest)
 	t.Run("Signing transaction succeeds", testAcceptSigningTransactionSucceeds)
 	t.Run("using ask function in policy succeeds", testAskingConsentPolicySucceeds)
+	t.Run("Checking transaction succeeds", testCheckTransactionSucceeds)
+	t.Run("Checking transaction with rejected transaction succeeds", testCheckTransactionWithRejectedTransactionSucceeds)
+	t.Run("Checking transaction with failed transaction fails", testCheckTransactionWithFailedTransactionFails)
 	t.Run("Decline signing transaction manually succeeds", testDeclineSigningTransactionManuallySucceeds)
 	t.Run("Signing transaction with propagation succeeds", testSigningTransactionWithPropagationSucceeds)
 	t.Run("Signing transaction with failed propagation fails", testSigningTransactionWithFailedPropagationFails)
@@ -694,6 +697,111 @@ func testServiceUpdateMetaFailInvalidRequest(t *testing.T) {
 	}
 }
 
+func testCheckTransactionSucceeds(t *testing.T) {
+	s := getTestService(t, "manual")
+	defer s.ctrl.Finish()
+
+	// given
+	walletName := vgrand.RandomStr(5)
+	token := vgrand.RandomStr(5)
+	headers := authHeaders(t, token)
+	pubKey := vgrand.RandomStr(5)
+	payload := fmt.Sprintf(`{"pubKey": "%s", "orderCancellation": {}}`, pubKey)
+
+	// setup
+	s.auth.EXPECT().VerifyToken(token).Times(1).Return(walletName, nil)
+	s.handler.EXPECT().SignTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(&commandspb.Transaction{}, nil)
+	s.nodeForward.EXPECT().CheckTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(&api.CheckTransactionResponse{
+		Success:   true,
+		Code:      0,
+		GasWanted: 300,
+		GasUsed:   200,
+	}, nil)
+	s.nodeForward.EXPECT().LastBlockHeightAndHash(gomock.Any()).Times(1).Return(&api.LastBlockHeightResponse{
+		Height:              42,
+		Hash:                "0292041e2f0cf741894503fb3ead4cb817bca2375e543aa70f7c4d938157b5a6",
+		SpamPowDifficulty:   2,
+		SpamPowHashFunction: "sha3_24_rounds",
+	}, 0, nil)
+	// when
+
+	statusCode, body := serveHTTP(t, s, checkTxRequest(t, payload, headers))
+	assert.Equal(t, http.StatusOK, statusCode)
+
+	resp := &api.CheckTransactionResponse{}
+	if err := json.Unmarshal(body, resp); err != nil {
+		t.Fatalf("couldn't unmarshal responde: %v", err)
+	}
+	assert.True(t, resp.Success)
+	assert.Equal(t, uint32(0), resp.Code)
+	assert.Equal(t, int64(300), resp.GasWanted)
+}
+
+func testCheckTransactionWithRejectedTransactionSucceeds(t *testing.T) {
+	s := getTestService(t, "manual")
+	defer s.ctrl.Finish()
+
+	// given
+	walletName := vgrand.RandomStr(5)
+	token := vgrand.RandomStr(5)
+	headers := authHeaders(t, token)
+	pubKey := vgrand.RandomStr(5)
+	payload := fmt.Sprintf(`{"pubKey": "%s", "orderCancellation": {}}`, pubKey)
+
+	// setup
+	s.auth.EXPECT().VerifyToken(token).Times(1).Return(walletName, nil)
+	s.handler.EXPECT().SignTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(&commandspb.Transaction{}, nil)
+	s.nodeForward.EXPECT().CheckTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(&api.CheckTransactionResponse{
+		Success: false,
+		Code:    4,
+	}, nil)
+	s.nodeForward.EXPECT().LastBlockHeightAndHash(gomock.Any()).Times(1).Return(&api.LastBlockHeightResponse{
+		Height:              42,
+		Hash:                "0292041e2f0cf741894503fb3ead4cb817bca2375e543aa70f7c4d938157b5a6",
+		SpamPowDifficulty:   2,
+		SpamPowHashFunction: "sha3_24_rounds",
+	}, 0, nil)
+	// when
+
+	statusCode, body := serveHTTP(t, s, checkTxRequest(t, payload, headers))
+	assert.Equal(t, http.StatusOK, statusCode)
+
+	resp := &api.CheckTransactionResponse{}
+	if err := json.Unmarshal(body, resp); err != nil {
+		t.Fatalf("couldn't unmarshal responde: %v", err)
+	}
+	assert.False(t, resp.Success)
+	assert.Equal(t, uint32(4), resp.Code)
+	assert.Equal(t, int64(0), resp.GasWanted)
+}
+
+func testCheckTransactionWithFailedTransactionFails(t *testing.T) {
+	s := getTestService(t, "manual")
+	defer s.ctrl.Finish()
+
+	// given
+	walletName := vgrand.RandomStr(5)
+	token := vgrand.RandomStr(5)
+	headers := authHeaders(t, token)
+	pubKey := vgrand.RandomStr(5)
+	payload := fmt.Sprintf(`{"pubKey": "%s", "orderCancellation": {}}`, pubKey)
+
+	// setup
+	s.auth.EXPECT().VerifyToken(token).Times(1).Return(walletName, nil)
+	s.handler.EXPECT().SignTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(&commandspb.Transaction{}, nil)
+	s.nodeForward.EXPECT().CheckTx(gomock.Any(), gomock.Any(), gomock.Any()).Times(1).Return(nil, assert.AnError)
+	s.nodeForward.EXPECT().LastBlockHeightAndHash(gomock.Any()).Times(1).Return(&api.LastBlockHeightResponse{
+		Height:              42,
+		Hash:                "0292041e2f0cf741894503fb3ead4cb817bca2375e543aa70f7c4d938157b5a6",
+		SpamPowDifficulty:   2,
+		SpamPowHashFunction: "sha3_24_rounds",
+	}, 0, nil)
+	// when
+
+	statusCode, _ := serveHTTP(t, s, checkTxRequest(t, payload, headers))
+	assert.Equal(t, http.StatusInternalServerError, statusCode)
+}
+
 func testAcceptSigningTransactionSucceeds(t *testing.T) {
 	s := getTestService(t, "manual")
 	defer s.ctrl.Finish()
@@ -1096,6 +1204,11 @@ func annotateKeyRequest(t *testing.T, id, payload string, headers map[string]str
 	return buildRequest(t, http.MethodPut, fmt.Sprintf("/api/v1/keys/%s/metadata", id), payload, headers)
 }
 
+func checkTxRequest(t *testing.T, payload string, headers map[string]string) *http.Request {
+	t.Helper()
+	return buildRequest(t, http.MethodPost, "/api/v1/command/check", payload, headers)
+}
+
 func signTxRequest(t *testing.T, payload string, headers map[string]string) *http.Request {
 	t.Helper()
 	return buildRequest(t, http.MethodPost, "/api/v1/command", payload, headers)