diff --git a/docs/docs.go b/docs/docs.go index e620c05..f2868e5 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -77,6 +77,12 @@ const docTemplate = `{ ], "summary": "Get Active Finality Providers", "parameters": [ + { + "type": "string", + "description": "Public key of the finality provider to fetch", + "name": "fp_btc_pk", + "in": "query" + }, { "type": "string", "description": "Pagination key to fetch the next page of finality providers", diff --git a/docs/swagger.json b/docs/swagger.json index a8c43e0..d30841e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -69,6 +69,12 @@ ], "summary": "Get Active Finality Providers", "parameters": [ + { + "type": "string", + "description": "Public key of the finality provider to fetch", + "name": "fp_btc_pk", + "in": "query" + }, { "type": "string", "description": "Pagination key to fetch the next page of finality providers", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 8915922..edd2734 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -275,6 +275,10 @@ paths: description: Fetches details of all active finality providers sorted by their active total value locked (ActiveTvl) in descending order. parameters: + - description: Public key of the finality provider to fetch + in: query + name: fp_btc_pk + type: string - description: Pagination key to fetch the next page of finality providers in: query name: pagination_key diff --git a/internal/api/handlers/finality_provider.go b/internal/api/handlers/finality_provider.go index 2841ea6..2c0106c 100644 --- a/internal/api/handlers/finality_provider.go +++ b/internal/api/handlers/finality_provider.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/babylonlabs-io/staking-api-service/internal/services" "github.com/babylonlabs-io/staking-api-service/internal/types" ) @@ -10,10 +11,28 @@ import ( // @Summary Get Active Finality Providers // @Description Fetches details of all active finality providers sorted by their active total value locked (ActiveTvl) in descending order. // @Produce json +// @Param fp_btc_pk query string false "Public key of the finality provider to fetch" // @Param pagination_key query string false "Pagination key to fetch the next page of finality providers" // @Success 200 {object} PublicResponse[[]services.FpDetailsPublic] "A list of finality providers sorted by ActiveTvl in descending order" // @Router /v1/finality-providers [get] func (h *Handler) GetFinalityProviders(request *http.Request) (*Result, *types.Error) { + fpPk, err := parsePublicKeyQuery(request, "fp_btc_pk", true) + if err != nil { + return nil, err + } + if fpPk != "" { + var result []*services.FpDetailsPublic + fp, err := h.services.GetFinalityProvider(request.Context(), fpPk) + if err != nil { + return nil, err + } + if fp != nil { + result = append(result, fp) + } + + return NewResult(result), nil + } + paginationKey, err := parsePaginationQuery(request) if err != nil { return nil, err diff --git a/internal/api/handlers/handler.go b/internal/api/handlers/handler.go index 9f6b66e..288e33a 100644 --- a/internal/api/handlers/handler.go +++ b/internal/api/handlers/handler.go @@ -64,9 +64,12 @@ func parsePaginationQuery(r *http.Request) (string, *types.Error) { return pageKey, nil } -func parsePublicKeyQuery(r *http.Request, queryName string) (string, *types.Error) { +func parsePublicKeyQuery(r *http.Request, queryName string, isOptional bool) (string, *types.Error) { pkHex := r.URL.Query().Get(queryName) if pkHex == "" { + if isOptional { + return "", nil + } return "", types.NewErrorWithMsg( http.StatusBadRequest, types.BadRequest, queryName+" is required", ) diff --git a/internal/api/handlers/staker.go b/internal/api/handlers/staker.go index 6639809..46f33d8 100644 --- a/internal/api/handlers/staker.go +++ b/internal/api/handlers/staker.go @@ -17,7 +17,7 @@ import ( // @Failure 400 {object} types.Error "Error: Bad Request" // @Router /v1/staker/delegations [get] func (h *Handler) GetStakerDelegations(request *http.Request) (*Result, *types.Error) { - stakerBtcPk, err := parsePublicKeyQuery(request, "staker_btc_pk") + stakerBtcPk, err := parsePublicKeyQuery(request, "staker_btc_pk", false) if err != nil { return nil, err } diff --git a/internal/services/finality_provider.go b/internal/services/finality_provider.go index 91ab391..292287b 100644 --- a/internal/services/finality_provider.go +++ b/internal/services/finality_provider.go @@ -63,6 +63,75 @@ func (s *Services) GetFinalityProvidersFromGlobalParams() []*FpParamsPublic { return fpDetails } +func (s *Services) GetFinalityProvider( + ctx context.Context, fpPkHex string, +) (*FpDetailsPublic, *types.Error) { + fpStatsByPks, err := + s.DbClient.FindFinalityProviderStatsByFinalityProviderPkHex( + ctx, []string{fpPkHex}, + ) + if err != nil { + log.Ctx(ctx).Error().Err(err). + Msg("Error while fetching finality provider from DB") + return nil, types.NewInternalServiceError(err) + } + + fpParams := s.GetFinalityProvidersFromGlobalParams() + // Found nothing in the DB, we will try get the FP from global params + if len(fpStatsByPks) == 0 { + for _, fp := range fpParams { + if fp.BtcPk == fpPkHex { + return &FpDetailsPublic{ + Description: fp.Description, + Commission: fp.Commission, + BtcPk: fp.BtcPk, + ActiveTvl: 0, + TotalTvl: 0, + ActiveDelegations: 0, + TotalDelegations: 0, + }, nil + } + } + // Return nil as nothing was found + return nil, nil + } + if len(fpStatsByPks) > 1 { + log.Ctx(ctx).Error(). + Str("fpPkHex", fpPkHex). + Int("numFinalityProviders", len(fpStatsByPks)). + Msg("Found more than one finality provider with the same pk") + return nil, types.NewErrorWithMsg( + http.StatusInternalServerError, types.InternalServiceError, + "Found more than one finality provider with the same pk", + ) + } + fpStat := fpStatsByPks[0] + + var fpParamsPublic *FpParamsPublic + for _, fp := range fpParams { + if fp.BtcPk == fpStat.FinalityProviderPkHex { + fpParamsPublic = fp + break + } + } + if fpParamsPublic == nil { + fpParamsPublic = &FpParamsPublic{ + Description: emptyFpDescriptionPublic, + Commission: "", + BtcPk: fpStat.FinalityProviderPkHex, + } + } + return &FpDetailsPublic{ + Description: fpParamsPublic.Description, + Commission: fpParamsPublic.Commission, + BtcPk: fpStat.FinalityProviderPkHex, + ActiveTvl: fpStat.ActiveTvl, + TotalTvl: fpStat.TotalTvl, + ActiveDelegations: fpStat.ActiveDelegations, + TotalDelegations: fpStat.TotalDelegations, + }, nil +} + func (s *Services) GetFinalityProviders(ctx context.Context, page string) ([]*FpDetailsPublic, string, *types.Error) { fpParams := s.GetFinalityProvidersFromGlobalParams() if len(fpParams) == 0 { diff --git a/tests/integration_test/finality_provider_test.go b/tests/integration_test/finality_provider_test.go index c9b07dd..25fb1bb 100644 --- a/tests/integration_test/finality_provider_test.go +++ b/tests/integration_test/finality_provider_test.go @@ -28,22 +28,8 @@ const ( func shouldGetFinalityProvidersSuccessfully(t *testing.T, testServer *TestServer) { url := testServer.Server.URL + finalityProvidersPath defer testServer.Close() - // Make a GET request to the finality providers endpoint - resp, err := http.Get(url) - assert.NoError(t, err, "making GET request to finality providers endpoint should not fail") - defer resp.Body.Close() - - // Check that the status code is HTTP 200 OK - assert.Equal(t, http.StatusOK, resp.StatusCode, "expected HTTP 200 OK status") - - // Read the response body - bodyBytes, err := io.ReadAll(resp.Body) - assert.NoError(t, err, "reading response body should not fail") - - var responseBody handlers.PublicResponse[[]services.FpDetailsPublic] - err = json.Unmarshal(bodyBytes, &responseBody) - assert.NoError(t, err, "unmarshalling response body should not fail") + responseBody := fetchSuccessfulResponse[[]services.FpDetailsPublic](t, url) result := responseBody.Data assert.Equal(t, "Babylon Foundation 2", result[2].Description.Moniker) assert.Equal(t, "0.060000000000000000", result[1].Commission) @@ -98,6 +84,18 @@ func TestGetFinalityProviderReturn4xxErrorIfPageTokenInvalid(t *testing.T) { assert.Equal(t, http.StatusBadRequest, resp.StatusCode) } +func TestGetFinalityProviderReturn4xxErrorIfPkInvalid(t *testing.T) { + testServer := setupTestServer(t, nil) + url := testServer.Server.URL + finalityProvidersPath + "?fp_btc_pk=invalid" + defer testServer.Close() + // Make a GET request to the finality providers endpoint + resp, err := http.Get(url) + assert.NoError(t, err) + defer resp.Body.Close() + + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) +} + func FuzzGetFinalityProviderShouldReturnAllRegisteredFps(f *testing.F) { attachRandomSeedsToFuzzer(f, 100) f.Fuzz(func(t *testing.T, seed int64) { @@ -331,6 +329,70 @@ func FuzzShouldNotReturnDefaultFpFromParamsWhenPageTokenIsPresent(f *testing.F) }) } +func FuzzGetFinalityProvider(f *testing.F) { + attachRandomSeedsToFuzzer(f, 3) + f.Fuzz(func(t *testing.T, seed int64) { + r := rand.New(rand.NewSource(seed)) + fpParams, registeredFpsStats, notRegisteredFpsStats := setUpFinalityProvidersStatsDataSet(t, r, nil) + // Manually force a single value for the finality provider to be used in db mocking + fpStats := []*model.FinalityProviderStatsDocument{registeredFpsStats[0]} + + mockDB := new(testmock.DBClient) + mockDB.On("FindFinalityProviderStatsByFinalityProviderPkHex", + mock.Anything, mock.Anything, + ).Return(fpStats, nil) + + testServer := setupTestServer(t, &TestServerDependency{MockDbClient: mockDB, MockedFinalityProviders: fpParams}) + url := testServer.Server.URL + finalityProvidersPath + "?fp_btc_pk=" + fpParams[0].BtcPk + // Make a GET request to the finality providers endpoint + respBody := fetchSuccessfulResponse[[]services.FpDetailsPublic](t, url) + result := respBody.Data + assert.Equal(t, 1, len(result)) + assert.Equal(t, fpParams[0].Description.Moniker, result[0].Description.Moniker) + assert.Equal(t, fpParams[0].Commission, result[0].Commission) + assert.Equal(t, fpParams[0].BtcPk, result[0].BtcPk) + assert.Equal(t, registeredFpsStats[0].ActiveTvl, result[0].ActiveTvl) + assert.Equal(t, registeredFpsStats[0].TotalTvl, result[0].TotalTvl) + assert.Equal(t, registeredFpsStats[0].ActiveDelegations, result[0].ActiveDelegations) + assert.Equal(t, registeredFpsStats[0].TotalDelegations, result[0].TotalDelegations) + testServer.Close() + + // Test the API with a non-existent finality provider from notRegisteredFpsStats + fpStats = []*model.FinalityProviderStatsDocument{notRegisteredFpsStats[0]} + mockDB = new(testmock.DBClient) + mockDB.On("FindFinalityProviderStatsByFinalityProviderPkHex", + mock.Anything, mock.Anything, + ).Return(fpStats, nil) + testServer = setupTestServer(t, &TestServerDependency{ + MockDbClient: mockDB, MockedFinalityProviders: fpParams, + }) + notRegisteredFp := notRegisteredFpsStats[0] + url = testServer.Server.URL + + finalityProvidersPath + + "?fp_btc_pk=" + notRegisteredFp.FinalityProviderPkHex + respBody = fetchSuccessfulResponse[[]services.FpDetailsPublic](t, url) + result = respBody.Data + assert.Equal(t, 1, len(result)) + assert.Equal(t, "", result[0].Description.Moniker) + assert.Equal(t, "", result[0].Commission) + assert.Equal(t, notRegisteredFp.FinalityProviderPkHex, result[0].BtcPk) + assert.Equal(t, notRegisteredFp.ActiveTvl, result[0].ActiveTvl) + testServer.Close() + + // Test the API with a non-existent finality provider PK + randomPk, err := testutils.RandomPk() + testServer = setupTestServer(t, &TestServerDependency{ + MockedFinalityProviders: fpParams, + }) + defer testServer.Close() + assert.NoError(t, err, "generating random public key should not fail") + url = testServer.Server.URL + finalityProvidersPath + "?fp_btc_pk=" + randomPk + respBody = fetchSuccessfulResponse[[]services.FpDetailsPublic](t, url) + result = respBody.Data + assert.Equal(t, 0, len(result)) + }) +} + func generateFinalityProviderStatsDocument(r *rand.Rand, pk string) *model.FinalityProviderStatsDocument { return &model.FinalityProviderStatsDocument{ FinalityProviderPkHex: pk, diff --git a/tests/integration_test/setup.go b/tests/integration_test/setup.go index aaf94e0..3db449b 100644 --- a/tests/integration_test/setup.go +++ b/tests/integration_test/setup.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "math/rand" + "net/http" "net/http/httptest" "strings" "testing" @@ -15,11 +17,13 @@ import ( "github.com/babylonlabs-io/staking-queue-client/client" "github.com/go-chi/chi" "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" queueConfig "github.com/babylonlabs-io/staking-queue-client/config" "github.com/babylonlabs-io/staking-api-service/internal/api" + "github.com/babylonlabs-io/staking-api-service/internal/api/handlers" "github.com/babylonlabs-io/staking-api-service/internal/api/middlewares" "github.com/babylonlabs-io/staking-api-service/internal/clients" "github.com/babylonlabs-io/staking-api-service/internal/config" @@ -263,3 +267,22 @@ func buildActiveStakingEvent(t *testing.T, numOfEvenet int) []*client.ActiveStak func attachRandomSeedsToFuzzer(f *testing.F, numOfSeeds int) { bbndatagen.AddRandomSeedsToFuzzer(f, uint(numOfSeeds)) } + +func fetchSuccessfulResponse[T any](t *testing.T, url string) handlers.PublicResponse[T] { + // Make a GET request to the finality providers endpoint + resp, err := http.Get(url) + assert.NoError(t, err) + defer resp.Body.Close() + + // Check that the status code is HTTP 200 OK + assert.Equal(t, http.StatusOK, resp.StatusCode, "expected HTTP 200 OK status") + + // Read the response body + bodyBytes, err := io.ReadAll(resp.Body) + assert.NoError(t, err, "reading response body should not fail") + + var responseBody handlers.PublicResponse[T] + err = json.Unmarshal(bodyBytes, &responseBody) + assert.NoError(t, err, "unmarshalling response body should not fail") + return responseBody +}