From 8469e6fd3e918d44432ab66a1f6ee6bb8f27bfa3 Mon Sep 17 00:00:00 2001 From: gouthamp-stellar Date: Tue, 29 Oct 2024 14:06:55 -0400 Subject: [PATCH] httphandler: tss api endpoints (#54) * tables and interfaces for TSS * TSS tables and the channel interface * remove empty lines * update * adding semicolons adding semicolons * moving all migrations to one file * make hash primary key instead of xdr * missing , * remove the index on try_transaction_xdr and add column/index on try_transaction_hash ...also remove outgoing/incoming_status and have one status column * Squashed commit of the following: commit fb807aafeb9fe21a748c53997cc8c63a53e6924f Author: gouthamp-stellar Date: Tue Sep 3 10:28:49 2024 -0700 remove the index on try_transaction_xdr and add column/index on try_transaction_hash ...also remove outgoing/incoming_status and have one status column commit 6fc0dc295fd357d1a0cc04144e27660c4aec29de Author: gouthamp-stellar Date: Tue Sep 3 08:58:46 2024 -0700 missing , commit a9cf4e32d70e6b21248fca88aca546a774e89718 Author: gouthamp-stellar Date: Tue Sep 3 01:55:23 2024 -0700 make hash primary key instead of xdr commit c0f9d3269c7e681c6d37da805bcca5441d92d17d Author: gouthamp-stellar Date: Fri Aug 30 15:18:27 2024 -0700 moving all migrations to one file commit 2de9898a5fb03d93f7fe20ab1f26cf004dbe0aef Author: gouthamp-stellar Date: Fri Aug 30 15:16:53 2024 -0700 adding semicolons adding semicolons commit 373c71a216e8f192e4fcd1e5e6976362a6a5ed1e Author: gouthamp-stellar Date: Fri Aug 30 15:12:24 2024 -0700 update commit 3f9f9f0faf11172d633557cbc5f283349be9205f Author: gouthamp-stellar Date: Fri Aug 30 15:12:00 2024 -0700 remove empty lines commit 9920f48ea88a6094ff4e5b52708b48da4c87c137 Author: gouthamp-stellar Date: Fri Aug 30 15:06:40 2024 -0700 TSS tables and the channel interface commit a58d5190a8c5b461847e8d8717904667c1a9b4b1 Author: gouthamp-stellar Date: Fri Aug 30 10:55:22 2024 -0700 tables and interfaces for TSS * commit #2 * changing from RpcIngestTxResponse -> RpcGetIngestTxResponse * latest changes * add tests for ValidateOptions * changes based on comments * latest changes based on changes to interface * string -> RPCTXStatus * adding a transaction_builder.go which takes a list of operation xdrs and builds a transaction out of it * moving transaction_service to the utils dir * upper case Channel methods * latest changes * p.txService.NetworkPassPhrase() * last commit before writing unit tests * Making the transaction service more injectible and adding fields to the Payload * typo * typo * Update 2024-08-28.0-tss_transactions.sql * lint errors * go mod tidy * test cases + other changes * remoce unused mocks * error handler service returns errorHandlerService * changes based on comments * lint deadcode error - suppress for now * removed deadcode * changes after comments on transaction service pr * TSS Error Handler Service * removing print statements * Update transaction_service.go * responding to comments * remove commented code * tx service changes * remove comment * remove comment * latest tx service changes * adding a router + utils file * removing println * changed function name * commit #1 * Code() helper function on RPCTXCode * Code() * Delete .env.swp * adding a helpers file * removing BuildTestFeeBumpTransaction * adding to serve.go etc * casing * better test for Send * resolving merge conflicts * removing mockSleep * delete channels/mocks.go * sleep -> time.Sleep * incorporating Daniel's changes + comments * delete files * remove unused code * name change * remove commented code * moving the mocks file inside servicesmocks dir * name changes * refactor * changes to serve.go * checking error on route.Route * check error * merging main * fixing parsesendresp tests * commit changes * merging in changes from eror_handler_service * delete file * commit current changes before merging branch * rpc ingestor * delete file * fix lint errors * remove dead code * fix broken test + delete unused code * tidy mod * remove commented code * delete file * changes based on comments * removing test case that is not relevant anymore * changes based on prev pr comments * remove fmt.Println * merge latest error_handler_service branch + small changes * %s -> %w * U -> u * variable for channel name * account for NewStore returning an error * fix build * commit #1 * tss pool populator * typo * typo * tss api endpoints * spaces * changing the tss api endpoint paths * merge main * merged latest * typo in desc string * millisecond wait time * changes based on comments * typo * changes based on comments * fixing migrations * compare types directly on route * changes based on comments * remove unused funcs in tss_options.go --- cmd/serve.go | 1 - ...24-10-14.0-alter_tss_transaction_tries.sql | 2 +- internal/serve/httphandler/tss_handler.go | 122 ++++++++++ .../serve/httphandler/tss_handler_test.go | 212 ++++++++++++++++++ internal/serve/serve.go | 16 ++ internal/tss/store/store_test.go | 1 + 6 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 internal/serve/httphandler/tss_handler.go create mode 100644 internal/serve/httphandler/tss_handler_test.go 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)