diff --git a/cmd/serve.go b/cmd/serve.go index 2108e26..d0ce900 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -47,7 +47,6 @@ func (c *serveCmd) Command() *cobra.Command { utils.WebhookHandlerChannelMaxWorkersOptions(&cfg.WebhookHandlerServiceChannelMaxWorkers), utils.WebhookHandlerChannelMaxRetriesOption(&cfg.WebhookHandlerServiceChannelMaxRetries), utils.WebhookHandlerChannelMinWaitBtwnRetriesMSOption(&cfg.WebhookHandlerServiceChannelMinWaitBtwnRetriesMS), - { Name: "port", Usage: "Port to listen and serve on", diff --git a/internal/db/migrations/2024-10-14.0-alter_tss_transaction_tries.sql b/internal/db/migrations/2024-10-14.0-alter_tss_transaction_tries.sql index 4dee34b..d812725 100644 --- a/internal/db/migrations/2024-10-14.0-alter_tss_transaction_tries.sql +++ b/internal/db/migrations/2024-10-14.0-alter_tss_transaction_tries.sql @@ -12,4 +12,4 @@ ALTER TABLE tss_transaction_submission_tries DROP COLUMN result_xdr; ALTER TABLE tss_transaction_submission_tries - RENAME COLUMN code TO status; \ No newline at end of file + RENAME COLUMN code TO status; diff --git a/internal/serve/httphandler/tss_handler.go b/internal/serve/httphandler/tss_handler.go new file mode 100644 index 0000000..3925a4d --- /dev/null +++ b/internal/serve/httphandler/tss_handler.go @@ -0,0 +1,122 @@ +package httphandler + +import ( + "net/http" + + "github.com/stellar/go/support/log" + "github.com/stellar/go/support/render/httpjson" + "github.com/stellar/wallet-backend/internal/apptracker" + "github.com/stellar/wallet-backend/internal/serve/httperror" + "github.com/stellar/wallet-backend/internal/tss" + "github.com/stellar/wallet-backend/internal/tss/router" + "github.com/stellar/wallet-backend/internal/tss/store" + "github.com/stellar/wallet-backend/internal/tss/utils" +) + +type TSSHandler struct { + Router router.Router + Store store.Store + AppTracker apptracker.AppTracker + NetworkPassphrase string +} + +type Transaction struct { + Operations []string `json:"operations" validate:"required"` +} + +type TransactionSubmissionRequest struct { + WebhookURL string `json:"webhook" validate:"required"` + Transactions []Transaction `json:"transactions" validate:"required,gt=0"` +} + +type TransactionSubmissionResponse struct { + TransactionHashes []string `json:"transactionhashes"` +} + +func (t *TSSHandler) SubmitTransactions(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var reqParams TransactionSubmissionRequest + httpErr := DecodeJSONAndValidate(ctx, r, &reqParams, t.AppTracker) + if httpErr != nil { + httpErr.Render(w) + return + } + var transactionHashes []string + var payloads []tss.Payload + for _, transaction := range reqParams.Transactions { + tx, err := utils.BuildOriginalTransaction(transaction.Operations) + if err != nil { + httperror.BadRequest("bad operation xdr", nil).Render(w) + return + } + txHash, err := tx.HashHex(t.NetworkPassphrase) + if err != nil { + httperror.InternalServerError(ctx, "unable to hashhex transaction", err, nil, t.AppTracker).Render(w) + return + } + + txXDR, err := tx.Base64() + if err != nil { + httperror.InternalServerError(ctx, "unable to base64 transaction", err, nil, t.AppTracker).Render(w) + return + } + + payload := tss.Payload{ + TransactionHash: txHash, + TransactionXDR: txXDR, + WebhookURL: reqParams.WebhookURL, + } + + payloads = append(payloads, payload) + transactionHashes = append(transactionHashes, txHash) + } + httpjson.Render(w, TransactionSubmissionResponse{ + TransactionHashes: transactionHashes, + }, httpjson.JSON) + + for _, payload := range payloads { + err := t.Router.Route(payload) + if err != nil { + log.Errorf("unable to route payload: %v", err) + } + + } + +} + +type GetTransactionRequest struct { + TransactionHash string `json:"transactionhash" validate:"required"` +} + +type GetTransactionResponse struct { + Hash string `json:"transactionhash"` + XDR string `json:"transactionxdr"` + Status string `json:"status"` +} + +func (t *TSSHandler) GetTransaction(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var reqParams GetTransactionRequest + httpErr := DecodePathAndValidate(ctx, r, &reqParams, t.AppTracker) + if httpErr != nil { + httpErr.Render(w) + return + } + tx, err := t.Store.GetTransaction(ctx, reqParams.TransactionHash) + if err != nil { + httperror.InternalServerError(ctx, "unable to get transaction "+reqParams.TransactionHash, err, nil, t.AppTracker).Render(w) + return + } + + if tx == (store.Transaction{}) { + httperror.NotFound.Render(w) + } + + httpjson.Render(w, GetTransactionResponse{ + Hash: tx.Hash, + XDR: tx.XDR, + Status: tx.Status, + }, httpjson.JSON) +} diff --git a/internal/serve/httphandler/tss_handler_test.go b/internal/serve/httphandler/tss_handler_test.go new file mode 100644 index 0000000..d982a6d --- /dev/null +++ b/internal/serve/httphandler/tss_handler_test.go @@ -0,0 +1,212 @@ +package httphandler + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path" + "strings" + "testing" + + "github.com/go-chi/chi" + xdr3 "github.com/stellar/go-xdr/xdr3" + "github.com/stellar/go/keypair" + "github.com/stellar/go/txnbuild" + "github.com/stellar/wallet-backend/internal/apptracker" + "github.com/stellar/wallet-backend/internal/db" + "github.com/stellar/wallet-backend/internal/db/dbtest" + "github.com/stellar/wallet-backend/internal/tss" + "github.com/stellar/wallet-backend/internal/tss/router" + "github.com/stellar/wallet-backend/internal/tss/store" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" +) + +func TestSubmitTransactions(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + store, _ := store.NewStore(dbConnectionPool) + mockRouter := router.MockRouter{} + mockAppTracker := apptracker.MockAppTracker{} + + handler := &TSSHandler{ + Router: &mockRouter, + Store: store, + AppTracker: &mockAppTracker, + NetworkPassphrase: "testnet passphrase", + } + + const endpoint = "/tss/transactions" + + t.Run("invalid_request_bodies", func(t *testing.T) { + reqBody := `{}` + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) + + http.HandlerFunc(handler.SubmitTransactions).ServeHTTP(rw, req) + + resp := rw.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedRespBody := ` + { + "error": "Validation error.", + "extras": { + "transactions": "This field is required", + "webhookURL": "This field is required" + } + }` + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.JSONEq(t, expectedRespBody, string(respBody)) + + reqBody = fmt.Sprintf(`{ + "webhook": "localhost:8080", + "transactions": [{"operations": [%q]}] + }`, "ABCD") + rw = httptest.NewRecorder() + req = httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) + + http.HandlerFunc(handler.SubmitTransactions).ServeHTTP(rw, req) + + resp = rw.Result() + respBody, err = io.ReadAll(resp.Body) + require.NoError(t, err) + + expectedRespBody = `{"error": "bad operation xdr"}` + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.JSONEq(t, expectedRespBody, string(respBody)) + + }) + + t.Run("happy_path", func(t *testing.T) { + srcAccount := keypair.MustRandom().Address() + p := txnbuild.Payment{ + Destination: keypair.MustRandom().Address(), + Amount: "10", + Asset: txnbuild.NativeAsset{}, + SourceAccount: srcAccount, + } + op, _ := p.BuildXDR() + + var buf strings.Builder + enc := xdr3.NewEncoder(&buf) + _ = op.EncodeTo(enc) + + opXDR := buf.String() + opXDRBase64 := base64.StdEncoding.EncodeToString([]byte(opXDR)) + reqBody := fmt.Sprintf(`{ + "webhook": "localhost:8080", + "transactions": [{"operations": [%q]}] + }`, opXDRBase64) + + rw := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, endpoint, strings.NewReader(reqBody)) + + mockRouter. + On("Route", mock.Anything). + Return(nil). + Once() + + http.HandlerFunc(handler.SubmitTransactions).ServeHTTP(rw, req) + resp := rw.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var txSubmissionResp TransactionSubmissionResponse + _ = json.Unmarshal(respBody, &txSubmissionResp) + + assert.Equal(t, 1, len(txSubmissionResp.TransactionHashes)) + + mockRouter.AssertNumberOfCalls(t, "Route", 1) + }) +} + +func TestGetTransaction(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + store, _ := store.NewStore(dbConnectionPool) + mockRouter := router.MockRouter{} + mockAppTracker := apptracker.MockAppTracker{} + + handler := &TSSHandler{ + Router: &mockRouter, + Store: store, + AppTracker: &mockAppTracker, + NetworkPassphrase: "testnet passphrase", + } + + endpoint := "/tss/transactions" + + r := chi.NewRouter() + r.Route(endpoint, func(r chi.Router) { + r.Get("/{transactionhash}", handler.GetTransaction) + }) + + clearTransactions := func(ctx context.Context) { + _, err = dbConnectionPool.ExecContext(ctx, "TRUNCATE tss_transactions") + require.NoError(t, err) + } + + t.Run("returns_transaction", func(t *testing.T) { + txHash := "hash" + ctx := context.Background() + _ = store.UpsertTransaction(ctx, "localhost:8080/webhook", txHash, "xdr", tss.RPCTXStatus{OtherStatus: tss.NewStatus}) + req, err := http.NewRequest(http.MethodGet, path.Join(endpoint, txHash), nil) + require.NoError(t, err) + + // Serve request + rw := httptest.NewRecorder() + r.ServeHTTP(rw, req) + resp := rw.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var getTxResp GetTransactionResponse + _ = json.Unmarshal(respBody, &getTxResp) + + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "hash", getTxResp.Hash) + assert.Equal(t, "xdr", getTxResp.XDR) + assert.Equal(t, "NEW", getTxResp.Status) + + clearTransactions(ctx) + }) + + t.Run("return_empty_transaction", func(t *testing.T) { + txHash := "hash" + req, err := http.NewRequest(http.MethodGet, path.Join(endpoint, txHash), nil) + require.NoError(t, err) + + // Serve request + rw := httptest.NewRecorder() + r.ServeHTTP(rw, req) + resp := rw.Result() + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + var getTxResp GetTransactionResponse + _ = json.Unmarshal(respBody, &getTxResp) + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + assert.Empty(t, getTxResp.Hash) + assert.Empty(t, getTxResp.XDR) + assert.Empty(t, getTxResp.Status) + + }) + +} diff --git a/internal/serve/serve.go b/internal/serve/serve.go index bcf25b3..5c4eacd 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -90,6 +90,7 @@ type handlerDeps struct { DatabaseURL string SignatureVerifier auth.SignatureVerifier SupportedAssets []entities.Asset + NetworkPassphrase string // Services AccountService services.AccountService @@ -102,6 +103,7 @@ type handlerDeps struct { WebhookChannel tss.Channel TSSRouter tssrouter.Router PoolPopulator tssservices.PoolPopulator + TSSStore tssstore.Store // Error Tracker AppTracker apptracker.AppTracker } @@ -273,6 +275,7 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { AccountSponsorshipService: accountSponsorshipService, PaymentService: paymentService, AppTracker: cfg.AppTracker, + NetworkPassphrase: cfg.NetworkPassphrase, // TSS RPCCallerChannel: rpcCallerChannel, ErrorJitterChannel: errorJitterChannel, @@ -280,6 +283,7 @@ func initHandlerDeps(cfg Configs) (handlerDeps, error) { WebhookChannel: webhookChannel, TSSRouter: router, PoolPopulator: poolPopulator, + TSSStore: store, }, nil } @@ -348,6 +352,18 @@ func handler(deps handlerDeps) http.Handler { r.Post("/create-sponsored-account", handler.SponsorAccountCreation) r.Post("/create-fee-bump", handler.CreateFeeBumpTransaction) }) + + r.Route("/tss", func(r chi.Router) { + handler := &httphandler.TSSHandler{ + Router: deps.TSSRouter, + Store: deps.TSSStore, + AppTracker: deps.AppTracker, + NetworkPassphrase: deps.NetworkPassphrase, + } + + r.Get("/transactions/{transactionhash}", handler.GetTransaction) + r.Post("/transactions", handler.SubmitTransactions) + }) }) return mux diff --git a/internal/tss/store/store_test.go b/internal/tss/store/store_test.go index cb0a6d5..2a2a3e1 100644 --- a/internal/tss/store/store_test.go +++ b/internal/tss/store/store_test.go @@ -116,6 +116,7 @@ func TestGetTransaction(t *testing.T) { t.Run("transaction_exists", func(t *testing.T) { status := tss.RPCTXStatus{OtherStatus: tss.NewStatus} _ = store.UpsertTransaction(context.Background(), "localhost:8000", "hash", "xdr", status) + tx, err := store.GetTransaction(context.Background(), "hash") assert.Equal(t, "xdr", tx.XDR)