From 5c4cdbf968a5eff33226772c38f3966209469f4c Mon Sep 17 00:00:00 2001 From: jeremy-babylonchain Date: Thu, 18 Jul 2024 18:42:25 +0800 Subject: [PATCH] feat: add ordinal endpoint(ordinal service + unisat) and tests --- Makefile | 4 +- cmd/staking-api-service/main.go | 8 +- config/config-docker.yml | 13 +- config/config-local.yml | 13 +- docs/docs.go | 8 +- docs/ordinals.md | 55 +++++++ docs/swagger.json | 8 +- docs/swagger.yaml | 4 + internal/api/handlers/ordinals.go | 57 +++++++ internal/api/routes.go | 6 + internal/api/server.go | 4 +- internal/clients/base/base_client.go | 138 ++++++++++++++++ internal/clients/clients.go | 27 ++++ internal/clients/ordinals/interface.go | 19 +++ internal/clients/ordinals/ordinals.go | 91 +++++++++++ internal/clients/unisat/interface.go | 15 ++ internal/clients/unisat/unisat.go | 102 ++++++++++++ internal/config/assets.go | 25 +++ internal/config/config.go | 16 +- internal/config/ordinals.go | 37 +++++ internal/config/unisat.go | 42 +++++ internal/db/dbclient.go | 4 +- internal/observability/metrics/metrics.go | 26 ++++ internal/services/ordinals.go | 99 ++++++++++++ internal/services/services.go | 4 + internal/types/error.go | 2 + internal/types/utxo.go | 6 + tests/assets_checking_test.go | 182 ++++++++++++++++++++++ tests/config/config-test.yml | 15 +- tests/mocks/mock_db_client.go | 4 +- tests/mocks/mock_ordinal_client.go | 121 ++++++++++++++ tests/mocks/mock_unisat_client.go | 121 ++++++++++++++ tests/setup.go | 71 +++------ 33 files changed, 1279 insertions(+), 68 deletions(-) create mode 100644 docs/ordinals.md create mode 100644 internal/api/handlers/ordinals.go create mode 100644 internal/clients/base/base_client.go create mode 100644 internal/clients/clients.go create mode 100644 internal/clients/ordinals/interface.go create mode 100644 internal/clients/ordinals/ordinals.go create mode 100644 internal/clients/unisat/interface.go create mode 100644 internal/clients/unisat/unisat.go create mode 100644 internal/config/assets.go create mode 100644 internal/config/ordinals.go create mode 100644 internal/config/unisat.go create mode 100644 internal/services/ordinals.go create mode 100644 internal/types/utxo.go create mode 100644 tests/assets_checking_test.go create mode 100644 tests/mocks/mock_ordinal_client.go create mode 100644 tests/mocks/mock_unisat_client.go diff --git a/Makefile b/Makefile index 8108483..8fd72ee 100644 --- a/Makefile +++ b/Makefile @@ -57,7 +57,9 @@ run-unprocessed-events-replay-local: --replay generate-mock-interface: - cd internal/db && mockery --name=DBClient --output=../../tests/mocks --outpkg=dbmock --filename=mock_db_client.go + cd internal/db && mockery --name=DBClient --output=../../tests/mocks --outpkg=mocks --filename=mock_db_client.go + cd internal/clients/ordinals && mockery --name=OrdinalsClientInterface --output=../../../tests/mocks --outpkg=mocks --filename=mock_ordinal_client.go + cd internal/clients/unisat && mockery --name=UnisatClientInterface --output=../../../tests/mocks --outpkg=mocks --filename=mock_unisat_client.go tests: ./bin/local-startup.sh; diff --git a/cmd/staking-api-service/main.go b/cmd/staking-api-service/main.go index 5134bb4..082f8c9 100644 --- a/cmd/staking-api-service/main.go +++ b/cmd/staking-api-service/main.go @@ -7,6 +7,7 @@ import ( "github.com/babylonchain/staking-api-service/cmd/staking-api-service/cli" "github.com/babylonchain/staking-api-service/cmd/staking-api-service/scripts" "github.com/babylonchain/staking-api-service/internal/api" + "github.com/babylonchain/staking-api-service/internal/clients" "github.com/babylonchain/staking-api-service/internal/config" "github.com/babylonchain/staking-api-service/internal/db/model" "github.com/babylonchain/staking-api-service/internal/observability/metrics" @@ -58,12 +59,15 @@ func main() { if err != nil { log.Fatal().Err(err).Msg("error while setting up staking db model") } - services, err := services.New(ctx, cfg, params, finalityProviders) + + // initialize clients package which is used to interact with external services + clients := clients.New(cfg) + services, err := services.New(ctx, cfg, params, finalityProviders, clients) if err != nil { log.Fatal().Err(err).Msg("error while setting up staking services layer") } // Start the event queue processing - queues := queue.New(&cfg.Queue, services) + queues := queue.New(cfg.Queue, services) // Check if the replay flag is set if cli.GetReplayFlag() { diff --git a/config/config-docker.yml b/config/config-docker.yml index 4678dc1..305c4d4 100644 --- a/config/config-docker.yml +++ b/config/config-docker.yml @@ -4,7 +4,7 @@ server: write-timeout: 60s read-timeout: 60s idle-timeout: 60s - allowed-origins: [ "*" ] + allowed-origins: ["*"] log-level: debug btc-net: "mainnet" max-content-length: 4096 @@ -27,3 +27,14 @@ queue: metrics: host: 0.0.0.0 port: 2112 +assets: + max_utxos: 100 + ordinals: + host: "http://ord-poc.devnet.babylonchain.io" + port: 8888 + timeout: 1000 + unisat: + host: "https://open-api.unisat.io" + limit: 100 + timeout: 1000 + token: "add your token as ASSETS_UNISAT_TOKEN in environment variables" diff --git a/config/config-local.yml b/config/config-local.yml index 8f0a032..615466f 100644 --- a/config/config-local.yml +++ b/config/config-local.yml @@ -4,7 +4,7 @@ server: write-timeout: 60s read-timeout: 60s idle-timeout: 60s - allowed-origins: [ "*" ] + allowed-origins: ["*"] log-level: debug btc-net: "signet" max-content-length: 4096 @@ -27,3 +27,14 @@ queue: metrics: host: 0.0.0.0 port: 2112 +assets: + max_utxos: 100 + ordinals: + host: "http://ord-poc.devnet.babylonchain.io" + port: 8888 + timeout: 5000 + unisat: + host: "https://open-api-testnet.unisat.io" + limit: 100 + timeout: 5000 + token: "add your token as ASSETS_UNISAT_TOKEN in .env" diff --git a/docs/docs.go b/docs/docs.go index 13829d9..c2be2dc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -622,14 +622,18 @@ const docTemplate = `{ "VALIDATION_ERROR", "NOT_FOUND", "BAD_REQUEST", - "FORBIDDEN" + "FORBIDDEN", + "UNPROCESSABLE_ENTITY", + "REQUEST_TIMEOUT" ], "x-enum-varnames": [ "InternalServiceError", "ValidationError", "NotFound", "BadRequest", - "Forbidden" + "Forbidden", + "UnprocessableEntity", + "RequestTimeout" ] } } diff --git a/docs/ordinals.md b/docs/ordinals.md new file mode 100644 index 0000000..8e03907 --- /dev/null +++ b/docs/ordinals.md @@ -0,0 +1,55 @@ +# Ordinals in API system + +The Babylon Staking API allows for the option to deploy additional endpoints +that check whether a UTXO contains an inscription or not, with the aim to help +staking applications identify whether they should avoid spending a particular UTXO. +This is accomplished through a connection to the +[Ordinal Service](https://github.com/ordinals/ord) and +a connection to the Unisat API. +Due to Unisat being a payed service and applying rate limits, +the API initially tries to get the status of a UTXO through the Ordinals Service, +and if that fails, then contacts the Unisat API, +effectively using it as a back-up mechanism to handle downtime from the Ordinals Service. + +NOTE: To enable the optional ordinal API endpoint, you will need to provide the +`ordinal` and `unisat` configurations under `assets` + +## Ordinal service Client + +WIP + +## Unisat Service Client + +You can find more information about Unisat's Ordinal/BRC-20/Runes related endpoints at: +https://docs.unisat.io/ + +In our service, we only utilize the following endpoint: +- `/v1/indexer/address/{{address}}/inscription-utxo-data` + +### How to Use It + +1. Log in via https://developer.unisat.io/account/login (create an account if you don't have one). +2. Copy the `API-Key`. +3. Set the key as an environment variable named `UNISAT_TOKEN`. +4. Configure the values for `unisat.host`, `limit`, `timeout`, etc. Refer to `config-docker.yml`. +5. Ensure you also set up the `ordinals` configuration, as this is a dependency. +6. Call the POST endpoint `/v1/ordinals/verify-utxos` as shown in the example below: +7. The calls to unisat will only be triggered if the ordinal service is not responding or returning errors +```POST +{ + "utxos": [ + { + "txid": "143c33b4ff4450a60648aec6b4d086639322cb093195226c641ae4f0ae33c3f5", + "vout": 2 + }, + { + "txid": "be3877c8dedd716f026cc77ef3f04f940b40b064d1928247cff5bb08ef1ba58e", + "vout": 0 + }, + { + "txid": "d7f65a37f59088b3b4e4bc119727daa0a0dd8435a645c49e6a665affc109539d", + "vout": 0 + } + ], + "address": "tb1pyqjxwcdv6pfcaj2l565ludclz2pwu2k5azs6uznz8kml74kkma6qm0gzlv" +} diff --git a/docs/swagger.json b/docs/swagger.json index 7d6a63a..b07c264 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -611,14 +611,18 @@ "VALIDATION_ERROR", "NOT_FOUND", "BAD_REQUEST", - "FORBIDDEN" + "FORBIDDEN", + "UNPROCESSABLE_ENTITY", + "REQUEST_TIMEOUT" ], "x-enum-varnames": [ "InternalServiceError", "ValidationError", "NotFound", "BadRequest", - "Forbidden" + "Forbidden", + "UnprocessableEntity", + "RequestTimeout" ] } } diff --git a/docs/swagger.yaml b/docs/swagger.yaml index cc8393e..7876e6a 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -214,6 +214,8 @@ definitions: - NOT_FOUND - BAD_REQUEST - FORBIDDEN + - UNPROCESSABLE_ENTITY + - REQUEST_TIMEOUT type: string x-enum-varnames: - InternalServiceError @@ -221,6 +223,8 @@ definitions: - NotFound - BadRequest - Forbidden + - UnprocessableEntity + - RequestTimeout info: contact: {} paths: diff --git a/internal/api/handlers/ordinals.go b/internal/api/handlers/ordinals.go new file mode 100644 index 0000000..d69e149 --- /dev/null +++ b/internal/api/handlers/ordinals.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "github.com/babylonchain/staking-api-service/internal/types" + "github.com/babylonchain/staking-api-service/internal/utils" + "github.com/btcsuite/btcd/chaincfg" +) + +type VerifyUTXOsRequestPayload struct { + Address string `json:"address"` + UTXOs []types.UTXOIdentifier `json:"utxos"` +} + +func parseRequestPayload(request *http.Request, maxUTXOs uint32, netParam *chaincfg.Params) (*VerifyUTXOsRequestPayload, *types.Error) { + var payload VerifyUTXOsRequestPayload + if err := json.NewDecoder(request.Body).Decode(&payload); err != nil { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, "invalid input format") + } + utxos := payload.UTXOs + if len(utxos) == 0 { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, "empty UTXO array") + } + + if uint32(len(utxos)) > maxUTXOs { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, "too many UTXOs in the request") + } + + for _, utxo := range utxos { + if !utils.IsValidTxHash(utxo.Txid) { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, "invalid UTXO txid") + } else if utxo.Vout < 0 { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, "invalid UTXO vout") + } + } + + if err := utils.IsValidBtcAddress(payload.Address, netParam); err != nil { + return nil, types.NewErrorWithMsg(http.StatusBadRequest, types.BadRequest, err.Error()) + } + return &payload, nil +} + +func (h *Handler) VerifyUTXOs(request *http.Request) (*Result, *types.Error) { + inputs, err := parseRequestPayload(request, h.config.Assets.MaxUTXOs, h.config.Server.BTCNetParam) + if err != nil { + return nil, err + } + + results, err := h.services.VerifyUTXOs(request.Context(), inputs.UTXOs, inputs.Address) + if err != nil { + return nil, err + } + + return NewResult(results), nil +} diff --git a/internal/api/routes.go b/internal/api/routes.go index 40563e3..cb87dad 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -20,5 +20,11 @@ func (a *Server) SetupRoutes(r *chi.Mux) { r.Get("/v1/staker/delegation/check", registerHandler(handlers.CheckStakerDelegationExist)) r.Get("/v1/delegation", registerHandler(handlers.GetDelegationByTxHash)) + // Only register these routes if the asset has been configured + // The endpoints are used to check ordinals within the UTXOs + if a.cfg.Assets != nil { + r.Post("/v1/ordinals/verify-utxos", registerHandler(handlers.VerifyUTXOs)) + } + r.Get("/swagger/*", httpSwagger.WrapHandler) } diff --git a/internal/api/server.go b/internal/api/server.go index e00407e..64ee95f 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -17,6 +17,7 @@ import ( type Server struct { httpServer *http.Server handlers *handlers.Handler + cfg *config.Config } func New( @@ -52,6 +53,7 @@ func New( server := &Server{ httpServer: srv, handlers: handlers, + cfg: cfg, } server.SetupRoutes(r) return server, nil @@ -60,4 +62,4 @@ func New( func (a *Server) Start() error { log.Info().Msgf("Starting server on %s", a.httpServer.Addr) return a.httpServer.ListenAndServe() -} \ No newline at end of file +} diff --git a/internal/clients/base/base_client.go b/internal/clients/base/base_client.go new file mode 100644 index 0000000..278196e --- /dev/null +++ b/internal/clients/base/base_client.go @@ -0,0 +1,138 @@ +package baseclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/babylonchain/staking-api-service/internal/observability/metrics" + "github.com/babylonchain/staking-api-service/internal/types" + "github.com/rs/zerolog/log" +) + +var ALLOWED_METHODS = []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"} + +type BaseClient interface { + GetBaseURL() string + GetDefaultRequestTimeout() int + GetHttpClient() *http.Client +} + +type BaseClientOptions struct { + Timeout int + Path string + TemplatePath string // Metrics purpose + Headers map[string]string +} + +func isAllowedMethod(method string) bool { + for _, allowedMethod := range ALLOWED_METHODS { + if method == allowedMethod { + return true + } + } + return false +} + +func sendRequest[I any, R any]( + ctx context.Context, client BaseClient, method string, opts *BaseClientOptions, input *I, +) (*R, *types.Error) { + if !isAllowedMethod(method) { + return nil, types.NewInternalServiceError(fmt.Errorf("method %s is not allowed", method)) + } + url := fmt.Sprintf("%s%s", client.GetBaseURL(), opts.Path) + timeout := client.GetDefaultRequestTimeout() + // If timeout is set, use it instead of the default + if opts.Timeout != 0 { + timeout = opts.Timeout + } + // Set a timeout for the request + ctxWithTimeout, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Millisecond) + defer cancel() + + var req *http.Request + var requestError error + if input != nil && (method == http.MethodPost || method == http.MethodPut) { + body, err := json.Marshal(input) + if err != nil { + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, + types.InternalServiceError, + "failed to marshal request body", + ) + } + req, requestError = http.NewRequestWithContext(ctxWithTimeout, method, url, bytes.NewBuffer(body)) + } else { + req, requestError = http.NewRequestWithContext(ctxWithTimeout, method, url, nil) + } + if requestError != nil { + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, types.InternalServiceError, requestError.Error(), + ) + } + // Set headers + for key, value := range opts.Headers { + req.Header.Set(key, value) + } + + resp, err := client.GetHttpClient().Do(req) + if err != nil { + if ctx.Err() == context.DeadlineExceeded || err.Error() == "context canceled" { + return nil, types.NewErrorWithMsg( + http.StatusRequestTimeout, + types.RequestTimeout, + fmt.Sprintf("request timeout after %d ms at %s", timeout, url), + ) + } + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Sprintf("failed to send request to %s", url), + ) + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusInternalServerError { + return nil, types.NewErrorWithMsg( + resp.StatusCode, + types.InternalServiceError, + fmt.Sprintf("internal server error when calling %s", url), + ) + } else if resp.StatusCode >= http.StatusBadRequest { + return nil, types.NewErrorWithMsg( + resp.StatusCode, + types.BadRequest, + fmt.Sprintf("client error when calling %s", url), + ) + } + + var output R + if err := json.NewDecoder(resp.Body).Decode(&output); err != nil { + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, + types.InternalServiceError, + fmt.Sprintf("failed to decode response from %s", url), + ) + } + + return &output, nil +} + +func SendRequest[I any, R any]( + ctx context.Context, client BaseClient, method string, opts *BaseClientOptions, input *I, +) (*R, *types.Error) { + timer := metrics.StartClientRequestDurationTimer( + client.GetBaseURL(), method, opts.TemplatePath, + ) + result, err := sendRequest[I, R](ctx, client, method, opts, input) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msgf("failed to send request") + timer(err.StatusCode) + return nil, err + } + timer(http.StatusOK) + return result, err +} diff --git a/internal/clients/clients.go b/internal/clients/clients.go new file mode 100644 index 0000000..0ce24d9 --- /dev/null +++ b/internal/clients/clients.go @@ -0,0 +1,27 @@ +package clients + +import ( + "github.com/babylonchain/staking-api-service/internal/clients/ordinals" + "github.com/babylonchain/staking-api-service/internal/clients/unisat" + "github.com/babylonchain/staking-api-service/internal/config" +) + +type Clients struct { + Ordinals ordinals.OrdinalsClientInterface + Unisat unisat.UnisatClientInterface +} + +func New(cfg *config.Config) *Clients { + var ordinalsClient *ordinals.OrdinalsClient + var unisatClient *unisat.UnisatClient + // If the assets config is set, create the ordinal related clients + if cfg.Assets != nil { + ordinalsClient = ordinals.NewOrdinalsClient(cfg.Assets.Ordinals) + unisatClient = unisat.NewUnisatClient(cfg.Assets.Unisat) + } + + return &Clients{ + Ordinals: ordinalsClient, + Unisat: unisatClient, + } +} diff --git a/internal/clients/ordinals/interface.go b/internal/clients/ordinals/interface.go new file mode 100644 index 0000000..edd853b --- /dev/null +++ b/internal/clients/ordinals/interface.go @@ -0,0 +1,19 @@ +package ordinals + +import ( + "context" + "net/http" + + "github.com/babylonchain/staking-api-service/internal/types" +) + +type OrdinalsClientInterface interface { + GetBaseURL() string + GetDefaultRequestTimeout() int + GetHttpClient() *http.Client + /* + FetchUTXOInfos fetches UTXO information from the ordinal service + The response from ordinal service shall contain all requested UTXOs and in the same order as requested + */ + FetchUTXOInfos(ctx context.Context, utxos []types.UTXOIdentifier) ([]OrdinalsOutputResponse, *types.Error) +} diff --git a/internal/clients/ordinals/ordinals.go b/internal/clients/ordinals/ordinals.go new file mode 100644 index 0000000..493c817 --- /dev/null +++ b/internal/clients/ordinals/ordinals.go @@ -0,0 +1,91 @@ +package ordinals + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + + baseclient "github.com/babylonchain/staking-api-service/internal/clients/base" + "github.com/babylonchain/staking-api-service/internal/config" + "github.com/babylonchain/staking-api-service/internal/types" +) + +type OrdinalsOutputResponse struct { + Transaction string `json:"transaction"` // same as Txid + Inscriptions []string `json:"inscriptions"` + Runes json.RawMessage `json:"runes"` +} + +type OrdinalsClient struct { + config *config.OrdinalsConfig + defaultHeaders map[string]string + httpClient *http.Client +} + +func NewOrdinalsClient(config *config.OrdinalsConfig) *OrdinalsClient { + // Client is disabled if config is nil + if config == nil { + return nil + } + httpClient := &http.Client{} + headers := map[string]string{ + "Content-Type": "application/json", + "Accept": "application/json", + } + return &OrdinalsClient{ + config, + headers, + httpClient, + } +} + +// Necessary for the BaseClient interface +func (c *OrdinalsClient) GetBaseURL() string { + return fmt.Sprintf("%s:%s", c.config.Host, c.config.Port) +} + +func (c *OrdinalsClient) GetDefaultRequestTimeout() int { + return c.config.Timeout +} + +func (c *OrdinalsClient) GetHttpClient() *http.Client { + return c.httpClient +} + +func (c *OrdinalsClient) FetchUTXOInfos( + ctx context.Context, utxos []types.UTXOIdentifier, +) ([]OrdinalsOutputResponse, *types.Error) { + path := "/outputs" + opts := &baseclient.BaseClientOptions{ + Path: path, + TemplatePath: path, + Headers: c.defaultHeaders, + } + + var txHashVouts []string + for _, utxo := range utxos { + txHashVouts = append(txHashVouts, fmt.Sprintf("%s:%d", utxo.Txid, utxo.Vout)) + } + + outputsResponse, err := baseclient.SendRequest[[]string, []OrdinalsOutputResponse]( + ctx, c, http.MethodPost, opts, &txHashVouts, + ) + if err != nil { + return nil, err + } + outputs := *outputsResponse + + // The response from ordinal service shall contain all requested UTXOs and in the same order + for i, utxo := range utxos { + if outputs[i].Transaction != utxo.Txid { + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, + types.InternalServiceError, + "response does not contain all requested UTXOs or in the wrong order", + ) + } + } + + return outputs, nil +} diff --git a/internal/clients/unisat/interface.go b/internal/clients/unisat/interface.go new file mode 100644 index 0000000..5dd2958 --- /dev/null +++ b/internal/clients/unisat/interface.go @@ -0,0 +1,15 @@ +package unisat + +import ( + "context" + "net/http" + + "github.com/babylonchain/staking-api-service/internal/types" +) + +type UnisatClientInterface interface { + GetBaseURL() string + GetDefaultRequestTimeout() int + GetHttpClient() *http.Client + FetchInscriptionsUtxosByAddress(ctx context.Context, address string, cursor uint32) ([]*UnisatUTXO, *types.Error) +} diff --git a/internal/clients/unisat/unisat.go b/internal/clients/unisat/unisat.go new file mode 100644 index 0000000..3127f98 --- /dev/null +++ b/internal/clients/unisat/unisat.go @@ -0,0 +1,102 @@ +package unisat + +import ( + "context" + "fmt" + "net/http" + + baseclient "github.com/babylonchain/staking-api-service/internal/clients/base" + "github.com/babylonchain/staking-api-service/internal/config" + "github.com/babylonchain/staking-api-service/internal/types" +) + +// Note: The JSON tags use camel case because this struct is used to +// represent the response from the Unisat endpoint, which uses camel case. +type UnisatInscriptions struct { + InscriptionId string `json:"inscriptionId"` + InscriptionNumber uint32 `json:"inscriptionNumber"` + IsBRC20 bool `json:"isBRC20"` + Moved bool `json:"moved"` + Offset uint32 `json:"offset"` +} + +type UnisatUTXO struct { + TxId string `json:"txid"` + Vout uint32 `json:"vout"` + Inscriptions []*UnisatInscriptions `json:"inscriptions"` +} + +type UnisatResponseData struct { + Cursor uint32 `json:"cursor"` + Total uint32 `json:"total"` + Utxo []*UnisatUTXO `json:"utxo"` +} + +// Refer to https://open-api.unisat.io/swagger.html +type UnisatResponse struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data UnisatResponseData `json:"data"` +} + +type UnisatClient struct { + config *config.UnisatConfig + httpClient *http.Client + defaultHeader map[string]string +} + +func NewUnisatClient(config *config.UnisatConfig) *UnisatClient { + // Client is disabled if config is nil + if config == nil { + return nil + } + httpClient := &http.Client{} + defaultHeader := map[string]string{ + "Accept": "application/json", + "Authorization": fmt.Sprintf("Bearer %s", config.ApiToken), + } + return &UnisatClient{ + config, + httpClient, + defaultHeader, + } +} + +// Necessary for the BaseClient interface +func (c *UnisatClient) GetBaseURL() string { + return fmt.Sprintf("%s", c.config.Host) +} + +func (c *UnisatClient) GetDefaultRequestTimeout() int { + return c.config.Timeout +} + +func (c *UnisatClient) GetHttpClient() *http.Client { + return c.httpClient +} + +// FetchInscriptionsUtxosByAddress fetches inscription UTXOs by address +// Refer to https://open-api.unisat.io/swagger.html#/address +// cursor and limit are used for pagination +func (c *UnisatClient) FetchInscriptionsUtxosByAddress( + ctx context.Context, address string, cursor uint32, +) ([]*UnisatUTXO, *types.Error) { + path := fmt.Sprintf( + "/v1/indexer/address/%s/inscription-utxo-data?cursor=%d&size=%d", + address, cursor, c.config.Limit, + ) + opts := &baseclient.BaseClientOptions{ + Path: path, + TemplatePath: "/v1/indexer/address/{address}/inscription-utxo-data", + Headers: c.defaultHeader, + } + + resp, err := baseclient.SendRequest[any, UnisatResponse]( + ctx, c, http.MethodGet, opts, nil, + ) + if err != nil { + return nil, err + } + + return resp.Data.Utxo, nil +} diff --git a/internal/config/assets.go b/internal/config/assets.go new file mode 100644 index 0000000..4916c6d --- /dev/null +++ b/internal/config/assets.go @@ -0,0 +1,25 @@ +package config + +import "errors" + +type AssetsConfig struct { + MaxUTXOs uint32 `mapstructure:"max_utxos"` + Ordinals *OrdinalsConfig `mapstructure:"ordinals"` + Unisat *UnisatConfig `mapstructure:"unisat"` +} + +func (cfg *AssetsConfig) Validate() error { + if err := cfg.Ordinals.Validate(); err != nil { + return err + } + + if err := cfg.Unisat.Validate(); err != nil { + return err + } + + if cfg.MaxUTXOs <= 0 { + return errors.New("max_utxos cannot be smaller or equal to 0") + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 513fb78..01b0217 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,10 +10,11 @@ import ( ) type Config struct { - Server ServerConfig `mapstructure:"server"` - Db DbConfig `mapstructure:"db"` - Queue queue.QueueConfig `mapstructure:"queue"` - Metrics MetricsConfig `mapstructure:"metrics"` + Server *ServerConfig `mapstructure:"server"` + Db *DbConfig `mapstructure:"db"` + Queue *queue.QueueConfig `mapstructure:"queue"` + Metrics *MetricsConfig `mapstructure:"metrics"` + Assets *AssetsConfig `mapstructure:"assets"` } func (cfg *Config) Validate() error { @@ -33,6 +34,13 @@ func (cfg *Config) Validate() error { return err } + // Assets is optional + if cfg.Assets != nil { + if err := cfg.Assets.Validate(); err != nil { + return err + } + } + return nil } diff --git a/internal/config/ordinals.go b/internal/config/ordinals.go new file mode 100644 index 0000000..34dc56d --- /dev/null +++ b/internal/config/ordinals.go @@ -0,0 +1,37 @@ +package config + +import ( + "errors" + "net/url" +) + +type OrdinalsConfig struct { + Host string `mapstructure:"host"` + Port string `mapstructure:"port"` + Timeout int `mapstructure:"timeout"` +} + +func (cfg *OrdinalsConfig) Validate() error { + if cfg.Host == "" { + return errors.New("host cannot be empty") + } + + if cfg.Port == "" { + return errors.New("port cannot be empty") + } + + if cfg.Timeout <= 0 { + return errors.New("timeout cannot be smaller or equal to 0") + } + + parsedURL, err := url.ParseRequestURI(cfg.Host) + if err != nil { + return errors.New("invalid ordinal service host") + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return errors.New("host must start with http or https") + } + + return nil +} diff --git a/internal/config/unisat.go b/internal/config/unisat.go new file mode 100644 index 0000000..91be2c0 --- /dev/null +++ b/internal/config/unisat.go @@ -0,0 +1,42 @@ +package config + +import ( + "errors" + "net/url" +) + +type UnisatConfig struct { + Host string `mapstructure:"host"` + Timeout int `mapstructure:"timeout"` + Limit uint32 `mapstructure:"limit"` + ApiToken string `mapstructure:"token"` +} + +func (cfg *UnisatConfig) Validate() error { + if cfg.Host == "" { + return errors.New("host cannot be empty") + } + + if cfg.Timeout <= 0 { + return errors.New("timeout cannot be smaller or equal to 0") + } + + if cfg.Limit <= 0 { + return errors.New("limit cannot be smaller or equal to 0") + } + + if cfg.ApiToken == "" { + return errors.New("api token cannot be empty") + } + + parsedURL, err := url.ParseRequestURI(cfg.Host) + if err != nil { + return errors.New("invalid unisat service host") + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return errors.New("host must start with http or https") + } + + return nil +} diff --git a/internal/db/dbclient.go b/internal/db/dbclient.go index b6b9149..650ba97 100644 --- a/internal/db/dbclient.go +++ b/internal/db/dbclient.go @@ -12,7 +12,7 @@ import ( type Database struct { DbName string Client *mongo.Client - cfg config.DbConfig + cfg *config.DbConfig } type DbResultMap[T any] struct { @@ -20,7 +20,7 @@ type DbResultMap[T any] struct { PaginationToken string `json:"paginationToken"` } -func New(ctx context.Context, cfg config.DbConfig) (*Database, error) { +func New(ctx context.Context, cfg *config.DbConfig) (*Database, error) { credential := options.Credential{ Username: cfg.Username, Password: cfg.Password, diff --git a/internal/observability/metrics/metrics.go b/internal/observability/metrics/metrics.go index 00777d9..84084fa 100644 --- a/internal/observability/metrics/metrics.go +++ b/internal/observability/metrics/metrics.go @@ -31,6 +31,7 @@ var ( unprocessableEntityCounter *prometheus.CounterVec queueOperationFailureCounter *prometheus.CounterVec httpResponseWriteFailureCounter *prometheus.CounterVec + clientRequestDurationHistogram *prometheus.HistogramVec ) // Init initializes the metrics package. @@ -103,12 +104,23 @@ func registerMetrics() { []string{"status"}, ) + // client requests are the ones sending to other service + clientRequestDurationHistogram = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "client_request_duration_seconds", + Help: "Histogram of outgoing client request durations in seconds.", + Buckets: defaultHistogramBucketsSeconds, + }, + []string{"baseurl", "method", "path", "status"}, + ) + prometheus.MustRegister( httpRequestDurationHistogram, eventProcessingDurationHistogram, unprocessableEntityCounter, queueOperationFailureCounter, httpResponseWriteFailureCounter, + clientRequestDurationHistogram, ) } @@ -151,3 +163,17 @@ func RecordQueueOperationFailure(operation, queuename string) { func RecordHttpResponseWriteFailure(statusCode int) { httpResponseWriteFailureCounter.WithLabelValues(fmt.Sprintf("%d", statusCode)).Inc() } + +// StartClientRequestDurationTimer starts a timer to measure outgoing client request duration. +func StartClientRequestDurationTimer(baseUrl, method, path string) func(statusCode int) { + startTime := time.Now() + return func(statusCode int) { + duration := time.Since(startTime).Seconds() + clientRequestDurationHistogram.WithLabelValues( + baseUrl, + method, + path, + fmt.Sprintf("%d", statusCode), + ).Observe(duration) + } +} diff --git a/internal/services/ordinals.go b/internal/services/ordinals.go new file mode 100644 index 0000000..b518896 --- /dev/null +++ b/internal/services/ordinals.go @@ -0,0 +1,99 @@ +package services + +import ( + "context" + "fmt" + + "github.com/babylonchain/staking-api-service/internal/clients/unisat" + "github.com/babylonchain/staking-api-service/internal/types" + "github.com/rs/zerolog/log" +) + +type SafeUTXOPublic struct { + TxId string `json:"txid"` + Vout uint32 `json:"vout"` + Inscription bool `json:"inscription"` +} + +func (s *Services) VerifyUTXOs( + ctx context.Context, utxos []types.UTXOIdentifier, address string, +) ([]*SafeUTXOPublic, *types.Error) { + result, err := s.verifyViaOrdinalService(ctx, utxos) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to verify ordinals via ordinals service") + unisatResult, err := s.verifyViaUnisatService(ctx, address, utxos) + if err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to verify ordinals via unisat service") + return nil, err + } + return unisatResult, nil + } + return result, nil +} + +func (s *Services) verifyViaOrdinalService(ctx context.Context, utxos []types.UTXOIdentifier) ([]*SafeUTXOPublic, *types.Error) { + var results []*SafeUTXOPublic + + outputs, err := s.Clients.Ordinals.FetchUTXOInfos(ctx, utxos) + if err != nil { + return nil, err + } + + for index, output := range outputs { + hasInscription := false + + // Check if Runes is not an empty JSON object + if len(output.Runes) > 0 && string(output.Runes) != "{}" { + hasInscription = true + } else if len(output.Inscriptions) > 0 { // Check if Inscriptions is not empty + hasInscription = true + } + results = append(results, &SafeUTXOPublic{ + TxId: output.Transaction, + Vout: utxos[index].Vout, + Inscription: hasInscription, + }) + } + + return results, nil +} + +func (s *Services) verifyViaUnisatService(ctx context.Context, address string, utxos []types.UTXOIdentifier) ([]*SafeUTXOPublic, *types.Error) { + cursor := uint32(0) + var inscriptionsUtxos []*unisat.UnisatUTXO + limit := s.cfg.Assets.Unisat.Limit + + for { + inscriptions, err := s.Clients.Unisat.FetchInscriptionsUtxosByAddress(ctx, address, cursor) + if err != nil { + return nil, err + } + // Append the fetched utxos to the list + inscriptionsUtxos = append(inscriptionsUtxos, inscriptions...) + // Stop fetching if the total number of utxos is less than the limit + if uint32(len(inscriptions)) < limit { + break + } + // update the cursor for the next fetch + cursor += limit + } + + // turn inscriptionsUtxos into a map for easier lookup + inscriptionsUtxosMap := make(map[string][]*unisat.UnisatInscriptions) + for _, inscriptionsUtxo := range inscriptionsUtxos { + key := fmt.Sprintf("%s:%d", inscriptionsUtxo.TxId, inscriptionsUtxo.Vout) + inscriptionsUtxosMap[key] = inscriptionsUtxo.Inscriptions + } + + var results []*SafeUTXOPublic + for _, utxo := range utxos { + key := fmt.Sprintf("%s:%d", utxo.Txid, utxo.Vout) + _, ok := inscriptionsUtxosMap[key] + results = append(results, &SafeUTXOPublic{ + TxId: utxo.Txid, + Vout: utxo.Vout, + Inscription: ok, + }) + } + return results, nil +} diff --git a/internal/services/services.go b/internal/services/services.go index e7e9fe8..c053e71 100644 --- a/internal/services/services.go +++ b/internal/services/services.go @@ -6,6 +6,7 @@ import ( "github.com/rs/zerolog/log" + "github.com/babylonchain/staking-api-service/internal/clients" "github.com/babylonchain/staking-api-service/internal/config" "github.com/babylonchain/staking-api-service/internal/db" "github.com/babylonchain/staking-api-service/internal/types" @@ -15,6 +16,7 @@ import ( // the database and other external clients (if any). type Services struct { DbClient db.DBClient + Clients *clients.Clients cfg *config.Config params *types.GlobalParams finalityProviders []types.FinalityProviderDetails @@ -25,6 +27,7 @@ func New( cfg *config.Config, globalParams *types.GlobalParams, finalityProviders []types.FinalityProviderDetails, + clients *clients.Clients, ) (*Services, error) { dbClient, err := db.New(ctx, cfg.Db) if err != nil { @@ -33,6 +36,7 @@ func New( } return &Services{ DbClient: dbClient, + Clients: clients, cfg: cfg, params: globalParams, finalityProviders: finalityProviders, diff --git a/internal/types/error.go b/internal/types/error.go index cede610..3466faf 100644 --- a/internal/types/error.go +++ b/internal/types/error.go @@ -18,6 +18,8 @@ const ( NotFound ErrorCode = "NOT_FOUND" BadRequest ErrorCode = "BAD_REQUEST" Forbidden ErrorCode = "FORBIDDEN" + UnprocessableEntity ErrorCode = "UNPROCESSABLE_ENTITY" + RequestTimeout ErrorCode = "REQUEST_TIMEOUT" ) // Error represents an error with an HTTP status code and an application-specific error code. diff --git a/internal/types/utxo.go b/internal/types/utxo.go new file mode 100644 index 0000000..0933f5a --- /dev/null +++ b/internal/types/utxo.go @@ -0,0 +1,6 @@ +package types + +type UTXOIdentifier struct { + Txid string `json:"txid"` + Vout uint32 `json:"vout"` +} diff --git a/tests/assets_checking_test.go b/tests/assets_checking_test.go new file mode 100644 index 0000000..765a961 --- /dev/null +++ b/tests/assets_checking_test.go @@ -0,0 +1,182 @@ +package tests + +import ( + "bytes" + "encoding/json" + "math/rand" + "net/http" + "testing" + + "github.com/babylonchain/staking-api-service/internal/api/handlers" + "github.com/babylonchain/staking-api-service/internal/clients" + "github.com/babylonchain/staking-api-service/internal/clients/ordinals" + "github.com/babylonchain/staking-api-service/internal/config" + "github.com/babylonchain/staking-api-service/internal/services" + "github.com/babylonchain/staking-api-service/internal/types" + "github.com/babylonchain/staking-api-service/internal/utils" + "github.com/babylonchain/staking-api-service/tests/mocks" + "github.com/btcsuite/btcd/chaincfg" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +const verifyUTXOsPath = "/v1/ordinals/verify-utxos" + +func createPayload(t *testing.T, r *rand.Rand, netParam *chaincfg.Params, size int) handlers.VerifyUTXOsRequestPayload { + var utxos []types.UTXOIdentifier + for i := 0; i < size; i++ { + tx, _, err := generateRandomTx(r) + if err != nil { + t.Fatalf("Failed to generate random tx: %v", err) + } + utxos = append(utxos, types.UTXOIdentifier{ + Txid: tx.TxHash().String(), + Vout: uint32(r.Intn(10)), + }) + } + pk, err := randomPk() + if err != nil { + t.Fatalf("Failed to generate random pk: %v", err) + } + address, err := utils.GetTaprootAddressFromPk(pk, netParam) + if err != nil { + t.Fatalf("Failed to generate taproot address from pk: %v", err) + } + return handlers.VerifyUTXOsRequestPayload{ + UTXOs: utxos, + Address: address, + } +} + +func TestVerifyUtxosEndpointNotAvailableIfAssetsConfigNotSet(t *testing.T) { + cfg, err := config.New("./config/config-test.yml") + if err != nil { + t.Fatalf("Failed to load test config: %v", err) + } + cfg.Assets = nil + + testServer := setupTestServer(t, &TestServerDependency{ConfigOverrides: cfg}) + defer testServer.Close() + + url := testServer.Server.URL + verifyUTXOsPath + resp, err := http.Post(url, "application/json", bytes.NewReader([]byte{})) + if err != nil { + t.Fatalf("Failed to make POST request to %s: %v", url, err) + } + defer resp.Body.Close() + + assert.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +func FuzzSuccessfullyVerifyUTXOsAssetsViaOrdinalService(f *testing.F) { + attachRandomSeedsToFuzzer(f, 10) + f.Fuzz(func(t *testing.T, seed int64) { + r := rand.New(rand.NewSource(seed)) + numOfUTXOs := randomPositiveInt(r, 100) + payload := createPayload(t, r, &chaincfg.MainNetParams, numOfUTXOs) + jsonPayload, err := json.Marshal(payload) + if err != nil { + t.Fatalf("Failed to marshal payload: %v", err) + } + + // create some ordinal responses that contains inscriptions + numOfUTXOsWithAsset := r.Intn(numOfUTXOs) + + var txidsWithAsset []string + for i := 0; i < numOfUTXOsWithAsset; i++ { + txidsWithAsset = append(txidsWithAsset, payload.UTXOs[i].Txid) + } + + mockedOrdinalResponse := createOrdinalServiceResponse(t, r, payload.UTXOs, txidsWithAsset) + + mockOrdinal := new(mocks.OrdinalsClientInterface) + mockOrdinal.On("FetchUTXOInfos", mock.Anything, mock.Anything).Return(mockedOrdinalResponse, nil) + mockedClients := &clients.Clients{ + Ordinals: mockOrdinal, + } + testServer := setupTestServer(t, &TestServerDependency{MockedClients: mockedClients}) + defer testServer.Close() + + url := testServer.Server.URL + verifyUTXOsPath + resp, err := http.Post(url, "application/json", bytes.NewReader(jsonPayload)) + if err != nil { + t.Fatalf("Failed to make POST request to %s: %v", url, err) + } + defer resp.Body.Close() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + // decode the response body + var response handlers.PublicResponse[[]services.SafeUTXOPublic] + err = json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + t.Fatalf("Failed to decode response body: %v", err) + } + + // check the response + assert.Equal(t, len(payload.UTXOs), len(response.Data)) + // check if the inscriptions are correctly returned and order is preserved + for i, u := range response.Data { + // Make sure the UTXO identifiers are correct + assert.Equal(t, payload.UTXOs[i].Txid, u.TxId) + assert.Equal(t, payload.UTXOs[i].Vout, u.Vout) + var isWithAsset bool + for _, txid := range txidsWithAsset { + if txid == u.TxId { + assert.True(t, u.Inscription) + isWithAsset = true + break + } + } + if !isWithAsset { + assert.False(t, u.Inscription) + } + } + }) +} + +// TODO: Test case 2: Fetching more than 100 UTXOs should return an error +// TODO: Test case 3: Invalid UTXO txid should return an error +// TODO: Test case 4: Ordinal service return error, fallback to unisat service and return the result +// TODO: Test case 5: Unisat service return error, return error +// TODO: Test case 6: Ordinal service took too long to respond, fallback to unisat service and return the result +// TODO: Test case 7: Unisat service took too long to respond, return error within the timeout window +// TODO: Test case 8: Send 100 UTXOs, ordinal service fail, unisat service return 100(limit), then fetch again for the remaining 1 item (test the pagination) +// TODO: Test case 9: Fall back to unisat when ordinal service return data that is not in the right order of the request UTXOs + +func createOrdinalServiceResponse(t *testing.T, r *rand.Rand, utxos []types.UTXOIdentifier, txidsWithAsset []string) []ordinals.OrdinalsOutputResponse { + var responses []ordinals.OrdinalsOutputResponse + + for _, utxo := range utxos { + withAsset := false + for _, txid := range txidsWithAsset { + if txid == utxo.Txid { + withAsset = true + break + } + } + if withAsset { + // randomly inject runes or inscriptions + if r.Intn(2) == 0 { + responses = append(responses, ordinals.OrdinalsOutputResponse{ + Transaction: utxo.Txid, + Inscriptions: []string{randomString(r, r.Intn(100))}, + Runes: json.RawMessage(`{}`), + }) + } else { + responses = append(responses, ordinals.OrdinalsOutputResponse{ + Transaction: utxo.Txid, + Inscriptions: []string{}, + Runes: json.RawMessage(`{"rune1": "rune1"}`), + }) + } + } else { + responses = append(responses, ordinals.OrdinalsOutputResponse{ + Transaction: utxo.Txid, + Inscriptions: []string{}, + Runes: json.RawMessage(`{}`), + }) + } + + } + return responses +} diff --git a/tests/config/config-test.yml b/tests/config/config-test.yml index 94940ce..5cec6fa 100644 --- a/tests/config/config-test.yml +++ b/tests/config/config-test.yml @@ -4,10 +4,10 @@ server: write-timeout: 60s read-timeout: 60s idle-timeout: 60s - allowed-origins: [ "*" ] + allowed-origins: ["*"] log-level: error btc-net: "signet" - max-content-length: 4096 + max-content-length: 40960 db: username: root password: example @@ -27,3 +27,14 @@ queue: metrics: host: 0.0.0.0 port: 2112 +assets: + max_utxos: 100 + ordinals: + host: "http://ord-poc.devnet.babylonchain.io" + port: 8888 + timeout: 100 + unisat: + host: "https://open-api-testnet.unisat.io" + limit: 100 + timeout: 100 + token: "add your token as ASSETS_UNISAT_TOKEN in .env" diff --git a/tests/mocks/mock_db_client.go b/tests/mocks/mock_db_client.go index ccf78c4..0874559 100644 --- a/tests/mocks/mock_db_client.go +++ b/tests/mocks/mock_db_client.go @@ -1,6 +1,6 @@ -// Code generated by mockery v2.43.0. DO NOT EDIT. +// Code generated by mockery v2.41.0. DO NOT EDIT. -package dbmock +package mocks import ( context "context" diff --git a/tests/mocks/mock_ordinal_client.go b/tests/mocks/mock_ordinal_client.go new file mode 100644 index 0000000..869ea67 --- /dev/null +++ b/tests/mocks/mock_ordinal_client.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.41.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + http "net/http" + + mock "github.com/stretchr/testify/mock" + + ordinals "github.com/babylonchain/staking-api-service/internal/clients/ordinals" + + types "github.com/babylonchain/staking-api-service/internal/types" +) + +// OrdinalsClientInterface is an autogenerated mock type for the OrdinalsClientInterface type +type OrdinalsClientInterface struct { + mock.Mock +} + +// FetchUTXOInfos provides a mock function with given fields: ctx, utxos +func (_m *OrdinalsClientInterface) FetchUTXOInfos(ctx context.Context, utxos []types.UTXOIdentifier) ([]ordinals.OrdinalsOutputResponse, *types.Error) { + ret := _m.Called(ctx, utxos) + + if len(ret) == 0 { + panic("no return value specified for FetchUTXOInfos") + } + + var r0 []ordinals.OrdinalsOutputResponse + var r1 *types.Error + if rf, ok := ret.Get(0).(func(context.Context, []types.UTXOIdentifier) ([]ordinals.OrdinalsOutputResponse, *types.Error)); ok { + return rf(ctx, utxos) + } + if rf, ok := ret.Get(0).(func(context.Context, []types.UTXOIdentifier) []ordinals.OrdinalsOutputResponse); ok { + r0 = rf(ctx, utxos) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]ordinals.OrdinalsOutputResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, []types.UTXOIdentifier) *types.Error); ok { + r1 = rf(ctx, utxos) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*types.Error) + } + } + + return r0, r1 +} + +// GetBaseURL provides a mock function with given fields: +func (_m *OrdinalsClientInterface) GetBaseURL() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetBaseURL") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetDefaultRequestTimeout provides a mock function with given fields: +func (_m *OrdinalsClientInterface) GetDefaultRequestTimeout() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetDefaultRequestTimeout") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetHttpClient provides a mock function with given fields: +func (_m *OrdinalsClientInterface) GetHttpClient() *http.Client { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetHttpClient") + } + + var r0 *http.Client + if rf, ok := ret.Get(0).(func() *http.Client); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Client) + } + } + + return r0 +} + +// NewOrdinalsClientInterface creates a new instance of OrdinalsClientInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOrdinalsClientInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *OrdinalsClientInterface { + mock := &OrdinalsClientInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tests/mocks/mock_unisat_client.go b/tests/mocks/mock_unisat_client.go new file mode 100644 index 0000000..debdef1 --- /dev/null +++ b/tests/mocks/mock_unisat_client.go @@ -0,0 +1,121 @@ +// Code generated by mockery v2.41.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + http "net/http" + + mock "github.com/stretchr/testify/mock" + + types "github.com/babylonchain/staking-api-service/internal/types" + + unisat "github.com/babylonchain/staking-api-service/internal/clients/unisat" +) + +// UnisatClientInterface is an autogenerated mock type for the UnisatClientInterface type +type UnisatClientInterface struct { + mock.Mock +} + +// FetchInscriptionsUtxosByAddress provides a mock function with given fields: ctx, address, cursor +func (_m *UnisatClientInterface) FetchInscriptionsUtxosByAddress(ctx context.Context, address string, cursor uint32) ([]*unisat.UnisatUTXO, *types.Error) { + ret := _m.Called(ctx, address, cursor) + + if len(ret) == 0 { + panic("no return value specified for FetchInscriptionsUtxosByAddress") + } + + var r0 []*unisat.UnisatUTXO + var r1 *types.Error + if rf, ok := ret.Get(0).(func(context.Context, string, uint32) ([]*unisat.UnisatUTXO, *types.Error)); ok { + return rf(ctx, address, cursor) + } + if rf, ok := ret.Get(0).(func(context.Context, string, uint32) []*unisat.UnisatUTXO); ok { + r0 = rf(ctx, address, cursor) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*unisat.UnisatUTXO) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, uint32) *types.Error); ok { + r1 = rf(ctx, address, cursor) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*types.Error) + } + } + + return r0, r1 +} + +// GetBaseURL provides a mock function with given fields: +func (_m *UnisatClientInterface) GetBaseURL() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetBaseURL") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetDefaultRequestTimeout provides a mock function with given fields: +func (_m *UnisatClientInterface) GetDefaultRequestTimeout() int { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetDefaultRequestTimeout") + } + + var r0 int + if rf, ok := ret.Get(0).(func() int); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(int) + } + + return r0 +} + +// GetHttpClient provides a mock function with given fields: +func (_m *UnisatClientInterface) GetHttpClient() *http.Client { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for GetHttpClient") + } + + var r0 *http.Client + if rf, ok := ret.Get(0).(func() *http.Client); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*http.Client) + } + } + + return r0 +} + +// NewUnisatClientInterface creates a new instance of UnisatClientInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewUnisatClientInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *UnisatClientInterface { + mock := &UnisatClientInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tests/setup.go b/tests/setup.go index 8e1b1f7..36b931b 100644 --- a/tests/setup.go +++ b/tests/setup.go @@ -7,8 +7,6 @@ import ( "log" "math/rand" "net/http/httptest" - "os" - "reflect" "strings" "testing" "time" @@ -25,6 +23,7 @@ import ( "github.com/babylonchain/staking-api-service/internal/api" "github.com/babylonchain/staking-api-service/internal/api/middlewares" + "github.com/babylonchain/staking-api-service/internal/clients" "github.com/babylonchain/staking-api-service/internal/config" "github.com/babylonchain/staking-api-service/internal/db" "github.com/babylonchain/staking-api-service/internal/observability/metrics" @@ -39,6 +38,7 @@ type TestServerDependency struct { PreInjectEventsHandler func(queueClient client.QueueClient) error MockedFinalityProviders []types.FinalityProviderDetails MockedGlobalParams *types.GlobalParams + MockedClients *clients.Clients } type TestServer struct { @@ -56,11 +56,22 @@ func (ts *TestServer) Close() { ts.channel.Close() } -func setupTestServer(t *testing.T, dep *TestServerDependency) *TestServer { +func loadTestConfig(t *testing.T) *config.Config { cfg, err := config.New("./config/config-test.yml") if err != nil { t.Fatalf("Failed to load test config: %v", err) } + return cfg +} + +func setupTestServer(t *testing.T, dep *TestServerDependency) *TestServer { + var err error + var cfg *config.Config + if dep != nil && dep.ConfigOverrides != nil { + cfg = dep.ConfigOverrides + } else { + cfg = loadTestConfig(t) + } metricsPort := cfg.Metrics.GetMetricsPort() metrics.Init(metricsPort) @@ -84,11 +95,14 @@ func setupTestServer(t *testing.T, dep *TestServerDependency) *TestServer { } } - if dep != nil && dep.ConfigOverrides != nil { - applyConfigOverrides(cfg, dep.ConfigOverrides) + var c *clients.Clients + if dep != nil && dep.MockedClients != nil { + c = dep.MockedClients + } else { + c = clients.New(cfg) } - services, err := services.New(context.Background(), cfg, params, fps) + services, err := services.New(context.Background(), cfg, params, fps, c) if err != nil { t.Fatalf("Failed to initialize services: %v", err) } @@ -113,7 +127,7 @@ func setupTestServer(t *testing.T, dep *TestServerDependency) *TestServer { r.Use(middlewares.ContentLengthMiddleware(cfg)) apiServer.SetupRoutes(r) - queues, conn, ch, err := setUpTestQueue(&cfg.Queue, services) + queues, conn, ch, err := setUpTestQueue(cfg.Queue, services) if err != nil { t.Fatalf("Failed to setup test queue: %v", err) } @@ -130,25 +144,6 @@ func setupTestServer(t *testing.T, dep *TestServerDependency) *TestServer { } } -// Generic function to apply configuration overrides -func applyConfigOverrides(defaultCfg *config.Config, overrides *config.Config) { - defaultVal := reflect.ValueOf(defaultCfg).Elem() - overrideVal := reflect.ValueOf(overrides).Elem() - - for i := 0; i < defaultVal.NumField(); i++ { - defaultField := defaultVal.Field(i) - overrideField := overrideVal.Field(i) - - if overrideField.IsZero() { - continue // Skip fields that are not set - } - - if defaultField.CanSet() { - defaultField.Set(overrideField) - } - } -} - // PurgeAllCollections drops all collections in the specified database. func PurgeAllCollections(ctx context.Context, client *mongo.Client, databaseName string) error { database := client.Database(databaseName) @@ -266,7 +261,7 @@ func sendTestMessage[T any](client client.QueueClient, data []T) error { return nil } -func directDbConnection(t *testing.T) (*db.Database) { +func directDbConnection(t *testing.T) *db.Database { cfg, err := config.New("./config/config-test.yml") if err != nil { t.Fatalf("Failed to load test config: %v", err) @@ -294,7 +289,7 @@ func injectDbDocuments[T any](t *testing.T, collectionName string, doc T) { // Inspect the items in the real database func inspectDbDocuments[T any](t *testing.T, collectionName string) ([]T, error) { - connection := directDbConnection(t) + connection := directDbConnection(t) collection := connection.Client.Database(connection.DbName).Collection(collectionName) cursor, err := collection.Find(context.Background(), bson.D{}) @@ -341,23 +336,3 @@ func buildActiveStakingEvent(t *testing.T, numOfEvenet int) []*client.ActiveStak } return activeStakingEvents } - -func createJsonFile(t *testing.T, jsonData []byte) string { - // Generate a random file name - rand.Seed(time.Now().UnixNano()) - fileName := fmt.Sprintf("test-%d.json", rand.Intn(1000)) - - // Create a temporary file - tempFile, err := os.CreateTemp("", fileName) - if err != nil { - t.Fatalf("error creating temporary file: %v", err) - } - defer tempFile.Close() // Ensure the file is closed before returning - - // Write the JSON data to the temporary file - if _, err := tempFile.Write(jsonData); err != nil { - t.Fatalf("error writing to temporary file: %v", err) - } - - return tempFile.Name() -}