diff --git a/config/config-docker.yml b/config/config-docker.yml index d0077cc4..f2395c90 100644 --- a/config/config-docker.yml +++ b/config/config-docker.yml @@ -41,4 +41,10 @@ assets: ordinals: host: "http://ord-poc.devnet.babylonchain.io" port: 8888 - timeout: 1000 \ No newline at end of file + timeout: 1000 +external_apis: + coinmarketcap: + api_key: ${COINMARKETCAP_API_KEY} + base_url: "https://pro-api.coinmarketcap.com/v1" + timeout: 10s # http client timeout + cache_ttl: 300s # mongodb ttl diff --git a/config/config-local.yml b/config/config-local.yml index 74a42d8e..90fbcdc5 100644 --- a/config/config-local.yml +++ b/config/config-local.yml @@ -38,4 +38,10 @@ assets: ordinals: host: "http://ord-poc.devnet.babylonchain.io" port: 8888 - timeout: 5000 \ No newline at end of file + timeout: 5000 +external_apis: + coinmarketcap: + api_key: ${COINMARKETCAP_API_KEY} + base_url: "https://pro-api.coinmarketcap.com/v1" + timeout: 10s # http client timeout + cache_ttl: 300s # mongodb ttl \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 30f38f67..a52dc4d0 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -559,6 +559,31 @@ const docTemplate = `{ } } }, + "/v2/prices": { + "get": { + "description": "Get latest prices for all available symbols", + "produces": [ + "application/json" + ], + "tags": [ + "v2" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.PublicResponse-map_string_float64" + } + }, + "400": { + "description": "Error: Bad Request", + "schema": { + "$ref": "#/definitions/github_com_babylonlabs-io_staking-api-service_internal_shared_types.Error" + } + } + } + } + }, "/v2/staker/stats": { "get": { "description": "Fetches staker stats for babylon staking including active tvl and active delegations.", @@ -715,6 +740,17 @@ const docTemplate = `{ } } }, + "handler.PublicResponse-map_string_float64": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/map_string_float64" + }, + "pagination": { + "$ref": "#/definitions/handler.paginationResponse" + } + } + }, "handler.PublicResponse-map_string_string": { "type": "object", "properties": { @@ -870,6 +906,12 @@ const docTemplate = `{ } } }, + "map_string_float64": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, "map_string_string": { "type": "object", "additionalProperties": { @@ -1057,6 +1099,10 @@ const docTemplate = `{ "active_tvl": { "type": "integer" }, + "btc_price_usd": { + "description": "Optional field", + "type": "number" + }, "pending_tvl": { "type": "integer" }, diff --git a/docs/swagger.json b/docs/swagger.json index 0b1c1380..eca84b9f 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -551,6 +551,31 @@ } } }, + "/v2/prices": { + "get": { + "description": "Get latest prices for all available symbols", + "produces": [ + "application/json" + ], + "tags": [ + "v2" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/handler.PublicResponse-map_string_float64" + } + }, + "400": { + "description": "Error: Bad Request", + "schema": { + "$ref": "#/definitions/github_com_babylonlabs-io_staking-api-service_internal_shared_types.Error" + } + } + } + } + }, "/v2/staker/stats": { "get": { "description": "Fetches staker stats for babylon staking including active tvl and active delegations.", @@ -707,6 +732,17 @@ } } }, + "handler.PublicResponse-map_string_float64": { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/map_string_float64" + }, + "pagination": { + "$ref": "#/definitions/handler.paginationResponse" + } + } + }, "handler.PublicResponse-map_string_string": { "type": "object", "properties": { @@ -862,6 +898,12 @@ } } }, + "map_string_float64": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, "map_string_string": { "type": "object", "additionalProperties": { @@ -1049,6 +1091,10 @@ "active_tvl": { "type": "integer" }, + "btc_price_usd": { + "description": "Optional field", + "type": "number" + }, "pending_tvl": { "type": "integer" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 9556731e..4f308573 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -52,6 +52,13 @@ definitions: pagination: $ref: '#/definitions/handler.paginationResponse' type: object + handler.PublicResponse-map_string_float64: + properties: + data: + $ref: '#/definitions/map_string_float64' + pagination: + $ref: '#/definitions/handler.paginationResponse' + type: object handler.PublicResponse-map_string_string: properties: data: @@ -152,6 +159,10 @@ definitions: version: type: integer type: object + map_string_float64: + additionalProperties: + type: number + type: object map_string_string: additionalProperties: type: string @@ -279,6 +290,9 @@ definitions: type: integer active_tvl: type: integer + btc_price_usd: + description: Optional field + type: number pending_tvl: type: integer total_delegations: @@ -914,6 +928,22 @@ paths: $ref: '#/definitions/github_com_babylonlabs-io_staking-api-service_internal_shared_types.Error' tags: - v2 + /v2/prices: + get: + description: Get latest prices for all available symbols + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/handler.PublicResponse-map_string_float64' + "400": + description: 'Error: Bad Request' + schema: + $ref: '#/definitions/github_com_babylonlabs-io_staking-api-service_internal_shared_types.Error' + tags: + - v2 /v2/staker/stats: get: description: Fetches staker stats for babylon staking including active tvl and diff --git a/go.mod b/go.mod index e519571c..a95c0076 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/btcsuite/btcd/btcec/v2 v2.3.2 github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 + github.com/miguelmota/go-coinmarketcap v0.1.8 github.com/robfig/cron/v3 v3.0.1 github.com/spf13/viper v1.19.0 github.com/swaggo/swag v1.16.3 diff --git a/go.sum b/go.sum index eb2a6838..c42067cd 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,7 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= +github.com/anaskhan96/soup v1.0.1/go.mod h1:pT5vs4HXDwA5y4KQCsKvnkpQd3D+joP7IqpiGskfWW0= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= @@ -569,6 +570,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg= +github.com/miguelmota/go-coinmarketcap v0.1.8 h1:rZhB7xs1j7qxxd1zftjADhAv6ECJQVhBom1dh3zURKY= +github.com/miguelmota/go-coinmarketcap v0.1.8/go.mod h1:hBjej1IiB5+pfj+0cZhnxRkAc2bgky8qWLhCJTQ3zjw= github.com/minio/highwayhash v1.0.3 h1:kbnuUMoHYyVl7szWjSxJnxw11k2U709jqFPPmIUyD6Q= github.com/minio/highwayhash v1.0.3/go.mod h1:GGYsuwP/fPD6Y9hMiXuapVvlIUEhFhMTh0rxU3ik1LQ= github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= @@ -895,6 +898,7 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20180215212450-dc948dff8834/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180719180050-a680a1efc54d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= diff --git a/internal/shared/api/handlers/handler/handler.go b/internal/shared/api/handlers/handler/handler.go index 186946a2..7f8acfa7 100644 --- a/internal/shared/api/handlers/handler/handler.go +++ b/internal/shared/api/handlers/handler/handler.go @@ -12,6 +12,7 @@ import ( "github.com/babylonlabs-io/staking-api-service/internal/shared/types" "github.com/babylonlabs-io/staking-api-service/internal/shared/utils" "github.com/btcsuite/btcd/chaincfg" + "strings" ) type Handler struct { @@ -86,6 +87,8 @@ func ParsePublicKeyQuery(r *http.Request, queryName string, isOptional bool) (st func ParseTxHashQuery(r *http.Request, queryName string) (string, *types.Error) { txHashHex := r.URL.Query().Get(queryName) + txHashHex = strings.ToLower(txHashHex) + if txHashHex == "" { return "", types.NewErrorWithMsg( http.StatusBadRequest, types.BadRequest, queryName+" is required", diff --git a/internal/shared/api/routes.go b/internal/shared/api/routes.go index 2e945497..5ded6b71 100644 --- a/internal/shared/api/routes.go +++ b/internal/shared/api/routes.go @@ -28,6 +28,7 @@ func (a *Server) SetupRoutes(r *chi.Mux) { r.Get("/v2/delegations", registerHandler(handlers.V2Handler.GetDelegations)) r.Get("/v2/stats", registerHandler(handlers.V2Handler.GetOverallStats)) r.Get("/v2/staker/stats", registerHandler(handlers.V2Handler.GetStakerStats)) + r.Get("/v2/prices", registerHandler(handlers.V2Handler.GetPrices)) // Legacy endpoints needed to support phase-1 delegations to unbond. // These will be deprecated once all phase-1 delegations are either withdrawn or registered into phase-2. diff --git a/internal/shared/config/config.go b/internal/shared/config/config.go index 1247b2a5..f2668a73 100644 --- a/internal/shared/config/config.go +++ b/internal/shared/config/config.go @@ -17,6 +17,7 @@ type Config struct { Metrics *MetricsConfig `mapstructure:"metrics"` Assets *AssetsConfig `mapstructure:"assets"` DelegationTransition *DelegationTransitionConfig `mapstructure:"delegation-transition"` + ExternalAPIs *ExternalAPIsConfig `mapstructure:"external_apis"` } func (cfg *Config) Validate() error { @@ -53,6 +54,13 @@ func (cfg *Config) Validate() error { } } + // ExternalAPIs is optional + if cfg.ExternalAPIs != nil { + if err := cfg.ExternalAPIs.Validate(); err != nil { + return err + } + } + return nil } diff --git a/internal/shared/config/external_apis.go b/internal/shared/config/external_apis.go new file mode 100644 index 00000000..75e27518 --- /dev/null +++ b/internal/shared/config/external_apis.go @@ -0,0 +1,45 @@ +package config + +import ( + "fmt" + "time" +) + +type ExternalAPIsConfig struct { + CoinMarketCap *CoinMarketCapConfig `mapstructure:"coinmarketcap"` +} + +type CoinMarketCapConfig struct { + APIKey string `mapstructure:"api_key"` + BaseURL string `mapstructure:"base_url"` + Timeout time.Duration `mapstructure:"timeout"` + CacheTTL time.Duration `mapstructure:"cache_ttl"` +} + +func (cfg *ExternalAPIsConfig) Validate() error { + if cfg.CoinMarketCap == nil { + return fmt.Errorf("missing coinmarketcap config") + } + + return cfg.CoinMarketCap.Validate() +} + +func (cfg *CoinMarketCapConfig) Validate() error { + if cfg.APIKey == "" { + return fmt.Errorf("missing coinmarketcap api key") + } + + if cfg.BaseURL == "" { + return fmt.Errorf("missing coinmarketcap base url") + } + + if cfg.Timeout <= 0 { + return fmt.Errorf("invalid coinmarketcap timeout") + } + + if cfg.CacheTTL <= 0 { + return fmt.Errorf("invalid coinmarketcap cache ttl") + } + + return nil +} diff --git a/internal/shared/db/client/btc_price.go b/internal/shared/db/client/btc_price.go new file mode 100644 index 00000000..1885c4d5 --- /dev/null +++ b/internal/shared/db/client/btc_price.go @@ -0,0 +1,39 @@ +package dbclient + +import ( + "context" + model "github.com/babylonlabs-io/staking-api-service/internal/shared/db/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo/options" + "strings" + "time" +) + +func (db *Database) GetLatestPrice(ctx context.Context, symbol string) (float64, error) { + symbol = strings.ToLower(symbol) + + client := db.Client.Database(db.DbName).Collection(model.PriceCollection) + var doc model.CoinPrice + err := client.FindOne(ctx, bson.M{"_id": symbol}).Decode(&doc) + if err != nil { + return 0, err + } + return doc.Price, nil +} + +func (db *Database) SetLatestPrice(ctx context.Context, symbol string, price float64) error { + symbol = strings.ToLower(symbol) + + doc := model.CoinPrice{ + ID: symbol, + Price: price, + CreatedAt: time.Now(), // For TTL index + } + opts := options.Update().SetUpsert(true) + filter := bson.M{"_id": symbol} + update := bson.M{"$set": doc} + + client := db.Client.Database(db.DbName).Collection(model.PriceCollection) + _, err := client.UpdateOne(ctx, filter, update, opts) + return err +} diff --git a/internal/shared/db/client/interface.go b/internal/shared/db/client/interface.go index cfd264a8..d9b3750a 100644 --- a/internal/shared/db/client/interface.go +++ b/internal/shared/db/client/interface.go @@ -32,4 +32,9 @@ type DBClient interface { SaveUnprocessableMessage(ctx context.Context, messageBody, receipt string) error FindUnprocessableMessages(ctx context.Context) ([]dbmodel.UnprocessableMessageDocument, error) DeleteUnprocessableMessage(ctx context.Context, Receipt interface{}) error + + // GetLatestPrice fetches symbol price from the database + GetLatestPrice(ctx context.Context, symbol string) (float64, error) + // SetLatestPrice sets the latest symbol price in the database + SetLatestPrice(ctx context.Context, symbol string, price float64) error } diff --git a/internal/shared/db/model/btc_price.go b/internal/shared/db/model/btc_price.go new file mode 100644 index 00000000..1eb85373 --- /dev/null +++ b/internal/shared/db/model/btc_price.go @@ -0,0 +1,11 @@ +package dbmodel + +import "time" + +const SymbolBTC = "btc" + +type CoinPrice struct { + ID string `bson:"_id"` // symbol + Price float64 `bson:"price"` + CreatedAt time.Time `bson:"created_at"` // TTL index will be on this field +} diff --git a/internal/shared/db/model/setup.go b/internal/shared/db/model/setup.go index d2516cb2..e642ad0e 100644 --- a/internal/shared/db/model/setup.go +++ b/internal/shared/db/model/setup.go @@ -11,6 +11,7 @@ import ( "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/mongo" "go.mongodb.org/mongo-driver/mongo/options" + "strings" ) const ( @@ -26,6 +27,7 @@ const ( V1UnbondingCollection = "unbonding_queue" V1BtcInfoCollection = "btc_info" V1UnprocessableMsgCollection = "unprocessable_messages" + PriceCollection = "prices" // V2 V2StatsLockCollection = "v2_stats_lock" V2OverallStatsCollection = "v2_overall_stats" @@ -93,6 +95,14 @@ func Setup(ctx context.Context, cfg *config.Config) error { } } + // If external APIs are configured, create TTL index for BTC price collection + if cfg.ExternalAPIs != nil { + if err := createTTLIndexes(ctx, database, cfg.ExternalAPIs.CoinMarketCap.CacheTTL); err != nil { + log.Error().Err(err).Msg("Failed to create TTL index for BTC price") + return err + } + } + log.Info().Msg("Collections and Indexes created successfully.") return nil } @@ -135,3 +145,24 @@ func createIndex(ctx context.Context, database *mongo.Database, collectionName s log.Debug().Msg("Index created successfully on collection: " + collectionName) } + +func createTTLIndexes(ctx context.Context, database *mongo.Database, cacheTTL time.Duration) error { + collection := database.Collection(PriceCollection) + // First, drop the existing TTL index if it exists + _, err := collection.Indexes().DropOne(ctx, "created_at_1") + if err != nil && !strings.Contains(err.Error(), "not found") { + return fmt.Errorf("failed to drop existing TTL index: %w", err) + } + // Create new TTL index + model := mongo.IndexModel{ + Keys: bson.D{{Key: "created_at", Value: 1}}, + Options: options.Index(). + SetExpireAfterSeconds(int32(cacheTTL.Seconds())). + SetName("created_at_1"), + } + _, err = collection.Indexes().CreateOne(ctx, model) + if err != nil { + return fmt.Errorf("failed to create TTL index: %w", err) + } + return nil +} diff --git a/internal/shared/http/clients/http_clients.go b/internal/shared/http/clients/http_clients.go index d7f1f0a5..93fd4bd8 100644 --- a/internal/shared/http/clients/http_clients.go +++ b/internal/shared/http/clients/http_clients.go @@ -3,10 +3,12 @@ package clients import ( "github.com/babylonlabs-io/staking-api-service/internal/shared/config" "github.com/babylonlabs-io/staking-api-service/internal/shared/http/clients/ordinals" + cmc "github.com/miguelmota/go-coinmarketcap/pro/v1" ) type Clients struct { - Ordinals ordinals.OrdinalsClient + Ordinals ordinals.OrdinalsClient + CoinMarketCap *cmc.Client } func New(cfg *config.Config) *Clients { @@ -16,7 +18,15 @@ func New(cfg *config.Config) *Clients { ordinalsClient = ordinals.New(cfg.Assets.Ordinals) } + var cmcClient *cmc.Client + if cfg.ExternalAPIs != nil && cfg.ExternalAPIs.CoinMarketCap != nil { + cmcClient = cmc.NewClient(&cmc.Config{ + ProAPIKey: cfg.ExternalAPIs.CoinMarketCap.APIKey, + }) + } + return &Clients{ - Ordinals: ordinalsClient, + Ordinals: ordinalsClient, + CoinMarketCap: cmcClient, } } diff --git a/internal/v1/service/stats.go b/internal/v1/service/stats.go index 54517c83..30adf2fe 100644 --- a/internal/v1/service/stats.go +++ b/internal/v1/service/stats.go @@ -11,13 +11,14 @@ import ( ) type OverallStatsPublic struct { - ActiveTvl int64 `json:"active_tvl"` - TotalTvl int64 `json:"total_tvl"` - ActiveDelegations int64 `json:"active_delegations"` - TotalDelegations int64 `json:"total_delegations"` - TotalStakers uint64 `json:"total_stakers"` - UnconfirmedTvl uint64 `json:"unconfirmed_tvl"` - PendingTvl uint64 `json:"pending_tvl"` + ActiveTvl int64 `json:"active_tvl"` + TotalTvl int64 `json:"total_tvl"` + ActiveDelegations int64 `json:"active_delegations"` + TotalDelegations int64 `json:"total_delegations"` + TotalStakers uint64 `json:"total_stakers"` + UnconfirmedTvl uint64 `json:"unconfirmed_tvl"` + PendingTvl uint64 `json:"pending_tvl"` + BtcPriceUsd *float64 `json:"btc_price_usd,omitempty"` // Optional field } type StakerStatsPublic struct { @@ -162,9 +163,7 @@ func (s *V1Service) GetOverallStats( return nil, types.NewInternalServiceError(err) } - unconfirmedTvl := uint64(0) - confirmedTvl := uint64(0) - pendingTvl := uint64(0) + var unconfirmedTvl, confirmedTvl, pendingTvl uint64 btcInfo, err := s.Service.DbClients.V1DBClient.GetLatestBtcInfo(ctx) if err != nil { diff --git a/internal/v2/api/handlers/stats.go b/internal/v2/api/handlers/stats.go index f99ed403..c9dbf540 100644 --- a/internal/v2/api/handlers/stats.go +++ b/internal/v2/api/handlers/stats.go @@ -44,3 +44,19 @@ func (h *V2Handler) GetOverallStats(request *http.Request) (*handler.Result, *ty } return handler.NewResult(stats), nil } + +// GetPrices @Summary Get latest prices for all available symbols +// @Description Get latest prices for all available symbols +// @Produce json +// @Tags v2 +// @Success 200 {object} handler.PublicResponse[map[string]float64] "" +// @Failure 400 {object} types.Error "Error: Bad Request" +// @Router /v2/prices [get] +func (h *V2Handler) GetPrices(request *http.Request) (*handler.Result, *types.Error) { + prices, err := h.Service.GetLatestPrices(request.Context()) + if err != nil { + return nil, err + } + + return handler.NewResult(prices), nil +} diff --git a/internal/v2/service/interface.go b/internal/v2/service/interface.go index 5ecdee65..bef38a18 100644 --- a/internal/v2/service/interface.go +++ b/internal/v2/service/interface.go @@ -15,6 +15,7 @@ type V2ServiceProvider interface { GetDelegations(ctx context.Context, stakerPKHex string, paginationKey string) ([]*DelegationPublic, string, *types.Error) MarkV1DelegationAsTransitioned(ctx context.Context, stakingTxHashHex string) *types.Error GetOverallStats(ctx context.Context) (*OverallStatsPublic, *types.Error) + GetLatestPrices(ctx context.Context) (map[string]float64, *types.Error) GetStakerStats(ctx context.Context, stakerPKHex string) (*StakerStatsPublic, *types.Error) ProcessAndSaveBtcAddresses(ctx context.Context, stakerPkHex string) *types.Error SaveUnprocessableMessages(ctx context.Context, messageBody, receipt string) *types.Error diff --git a/internal/v2/service/prices.go b/internal/v2/service/prices.go new file mode 100644 index 00000000..6347b99d --- /dev/null +++ b/internal/v2/service/prices.go @@ -0,0 +1,82 @@ +package v2service + +import ( + "context" + "errors" + "fmt" + dbmodel "github.com/babylonlabs-io/staking-api-service/internal/shared/db/model" + "github.com/babylonlabs-io/staking-api-service/internal/shared/types" + coinmarketcap "github.com/miguelmota/go-coinmarketcap/pro/v1" + "go.mongodb.org/mongo-driver/mongo" + "strings" +) + +func (s *V2Service) GetLatestPrices(ctx context.Context) (map[string]float64, *types.Error) { + // it happens in case config doesn't contain values to initialize coinmarketcap client + if s.Clients.CoinMarketCap == nil { + err := errors.New("coin market cap API is not configured") + return nil, types.NewInternalServiceError(err) + } + + btcPrice, err := s.getLatestBTCPrice(ctx) + if err != nil { + return nil, types.NewInternalServiceError(err) + } + + // for now we get only btc prices + btcSymbol := strings.ToUpper(dbmodel.SymbolBTC) + return map[string]float64{ + btcSymbol: btcPrice, + }, nil +} + +func (s *V2Service) getLatestBTCPrice(ctx context.Context) (float64, error) { + // Try to get price from MongoDB first + db := s.DbClients.SharedDBClient + price, err := db.GetLatestPrice(ctx, dbmodel.SymbolBTC) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + // Document not found, fetch from CoinMarketCap + + // singleflight prevents sending multiple requests for btc quote from multiple goroutine + // here we will make just 1 request, other goroutines will wait and receive whatever first one get + // note that key in .Do call below is not very important unless we use the same string + value, err, _ := s.singleFlightGroup.Do("fetch_btc", func() (any, error) { + return s.doGetLatestBTCPrice() + }) + if err != nil { + return 0, fmt.Errorf("failed to fetch price from CoinMarketCap: %w", err) + } + price := value.(float64) + // Store in MongoDB with TTL + if err := db.SetLatestPrice(ctx, dbmodel.SymbolBTC, price); err != nil { + return 0, fmt.Errorf("failed to cache btc price: %w", err) + } + return price, nil + } + // Handle other database errors + return 0, fmt.Errorf("database error: %w", err) + } + return price, nil +} + +func (s *V2Service) doGetLatestBTCPrice() (float64, error) { + quotes, err := s.Clients.CoinMarketCap.Cryptocurrency.LatestQuotes(&coinmarketcap.QuoteOptions{ + Symbol: "BTC", + }) + if err != nil { + return 0, err + } + + if len(quotes) != 1 { + return 0, fmt.Errorf("number of quotes from coinmarketcap != 1") + } + btcLatestQuote := quotes[0] + + btcToUsdQuote := btcLatestQuote.Quote["USD"] + if btcToUsdQuote == nil { + return 0, fmt.Errorf("USD quote not found in coinmarketcap response") + } + + return btcToUsdQuote.Price, nil +} diff --git a/internal/v2/service/service.go b/internal/v2/service/service.go index eaf0df0b..d58ec293 100644 --- a/internal/v2/service/service.go +++ b/internal/v2/service/service.go @@ -6,12 +6,15 @@ import ( "github.com/babylonlabs-io/staking-api-service/internal/shared/config" dbclients "github.com/babylonlabs-io/staking-api-service/internal/shared/db/clients" "github.com/babylonlabs-io/staking-api-service/internal/shared/http/clients" + "golang.org/x/sync/singleflight" ) type V2Service struct { DbClients *dbclients.DbClients Clients *clients.Clients Cfg *config.Config + + singleFlightGroup *singleflight.Group } func New( @@ -21,8 +24,9 @@ func New( dbClients *dbclients.DbClients, ) (*V2Service, error) { return &V2Service{ - DbClients: dbClients, - Clients: clients, - Cfg: cfg, + DbClients: dbClients, + Clients: clients, + Cfg: cfg, + singleFlightGroup: &singleflight.Group{}, }, nil } diff --git a/tests/mocks/mock_coinmarketcap_client.go b/tests/mocks/mock_coinmarketcap_client.go new file mode 100644 index 00000000..4deb08a7 --- /dev/null +++ b/tests/mocks/mock_coinmarketcap_client.go @@ -0,0 +1,59 @@ +// Code generated by mockery v2.51.0. DO NOT EDIT. + +package mocks + +import ( + context "context" + + types "github.com/babylonlabs-io/staking-api-service/internal/shared/types" + mock "github.com/stretchr/testify/mock" +) + +// CoinMarketCapClientInterface is an autogenerated mock type for the CoinMarketCapClientInterface type +type CoinMarketCapClientInterface struct { + mock.Mock +} + +// GetLatestBtcPrice provides a mock function with given fields: ctx +func (_m *CoinMarketCapClientInterface) GetLatestBtcPrice(ctx context.Context) (float64, *types.Error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for GetLatestPrice") + } + + var r0 float64 + var r1 *types.Error + if rf, ok := ret.Get(0).(func(context.Context) (float64, *types.Error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) float64); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(float64) + } + + if rf, ok := ret.Get(1).(func(context.Context) *types.Error); ok { + r1 = rf(ctx) + } else { + if ret.Get(1) != nil { + r1 = ret.Get(1).(*types.Error) + } + } + + return r0, r1 +} + +// NewCoinMarketCapClientInterface creates a new instance of CoinMarketCapClientInterface. 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 NewCoinMarketCapClientInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *CoinMarketCapClientInterface { + mock := &CoinMarketCapClientInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/tests/mocks/mock_db_client.go b/tests/mocks/mock_db_client.go index 948575d8..0d95fde1 100644 --- a/tests/mocks/mock_db_client.go +++ b/tests/mocks/mock_db_client.go @@ -123,6 +123,34 @@ func (_m *DBClient) FindUnprocessableMessages(ctx context.Context) ([]dbmodel.Un return r0, r1 } +// GetLatestPrice provides a mock function with given fields: ctx, symbol +func (_m *DBClient) GetLatestPrice(ctx context.Context, symbol string) (float64, error) { + ret := _m.Called(ctx, symbol) + + if len(ret) == 0 { + panic("no return value specified for GetLatestPrice") + } + + var r0 float64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (float64, error)); ok { + return rf(ctx, symbol) + } + if rf, ok := ret.Get(0).(func(context.Context, string) float64); ok { + r0 = rf(ctx, symbol) + } else { + r0 = ret.Get(0).(float64) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, symbol) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // InsertPkAddressMappings provides a mock function with given fields: ctx, stakerPkHex, taproot, nativeSigwitOdd, nativeSigwitEven func (_m *DBClient) InsertPkAddressMappings(ctx context.Context, stakerPkHex string, taproot string, nativeSigwitOdd string, nativeSigwitEven string) error { ret := _m.Called(ctx, stakerPkHex, taproot, nativeSigwitOdd, nativeSigwitEven) @@ -177,6 +205,24 @@ func (_m *DBClient) SaveUnprocessableMessage(ctx context.Context, messageBody st return r0 } +// SetLatestPrice provides a mock function with given fields: ctx, symbol, price +func (_m *DBClient) SetLatestPrice(ctx context.Context, symbol string, price float64) error { + ret := _m.Called(ctx, symbol, price) + + if len(ret) == 0 { + panic("no return value specified for SetLatestPrice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, float64) error); ok { + r0 = rf(ctx, symbol, price) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // NewDBClient creates a new instance of DBClient. 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 NewDBClient(t interface { diff --git a/tests/mocks/mock_v1_db_client.go b/tests/mocks/mock_v1_db_client.go index e8c3a043..db95b044 100644 --- a/tests/mocks/mock_v1_db_client.go +++ b/tests/mocks/mock_v1_db_client.go @@ -338,6 +338,34 @@ func (_m *V1DBClient) GetLatestBtcInfo(ctx context.Context) (*v1dbmodel.BtcInfo, return r0, r1 } +// GetLatestPrice provides a mock function with given fields: ctx, symbol +func (_m *V1DBClient) GetLatestPrice(ctx context.Context, symbol string) (float64, error) { + ret := _m.Called(ctx, symbol) + + if len(ret) == 0 { + panic("no return value specified for GetLatestPrice") + } + + var r0 float64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (float64, error)); ok { + return rf(ctx, symbol) + } + if rf, ok := ret.Get(0).(func(context.Context, string) float64); ok { + r0 = rf(ctx, symbol) + } else { + r0 = ret.Get(0).(float64) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, symbol) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetOrCreateStatsLock provides a mock function with given fields: ctx, stakingTxHashHex, state func (_m *V1DBClient) GetOrCreateStatsLock(ctx context.Context, stakingTxHashHex string, state string) (*v1dbmodel.StatsLockDocument, error) { ret := _m.Called(ctx, stakingTxHashHex, state) @@ -620,6 +648,24 @@ func (_m *V1DBClient) ScanDelegationsPaginated(ctx context.Context, paginationTo return r0, r1 } +// SetLatestPrice provides a mock function with given fields: ctx, symbol, price +func (_m *V1DBClient) SetLatestPrice(ctx context.Context, symbol string, price float64) error { + ret := _m.Called(ctx, symbol, price) + + if len(ret) == 0 { + panic("no return value specified for SetLatestPrice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, float64) error); ok { + r0 = rf(ctx, symbol, price) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SubtractFinalityProviderStats provides a mock function with given fields: ctx, stakingTxHashHex, fpPkHex, amount func (_m *V1DBClient) SubtractFinalityProviderStats(ctx context.Context, stakingTxHashHex string, fpPkHex string, amount uint64) error { ret := _m.Called(ctx, stakingTxHashHex, fpPkHex, amount) diff --git a/tests/mocks/mock_v2_db_client.go b/tests/mocks/mock_v2_db_client.go index e774eaac..e8fd6b31 100644 --- a/tests/mocks/mock_v2_db_client.go +++ b/tests/mocks/mock_v2_db_client.go @@ -182,6 +182,34 @@ func (_m *V2DBClient) GetFinalityProviderStats(ctx context.Context) ([]*v2dbmode return r0, r1 } +// GetLatestPrice provides a mock function with given fields: ctx, symbol +func (_m *V2DBClient) GetLatestPrice(ctx context.Context, symbol string) (float64, error) { + ret := _m.Called(ctx, symbol) + + if len(ret) == 0 { + panic("no return value specified for GetLatestPrice") + } + + var r0 float64 + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) (float64, error)); ok { + return rf(ctx, symbol) + } + if rf, ok := ret.Get(0).(func(context.Context, string) float64); ok { + r0 = rf(ctx, symbol) + } else { + r0 = ret.Get(0).(float64) + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, symbol) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // GetOrCreateStatsLock provides a mock function with given fields: ctx, stakingTxHashHex, state func (_m *V2DBClient) GetOrCreateStatsLock(ctx context.Context, stakingTxHashHex string, state string) (*v2dbmodel.V2StatsLockDocument, error) { ret := _m.Called(ctx, stakingTxHashHex, state) @@ -434,6 +462,24 @@ func (_m *V2DBClient) SaveUnprocessableMessage(ctx context.Context, messageBody return r0 } +// SetLatestPrice provides a mock function with given fields: ctx, symbol, price +func (_m *V2DBClient) SetLatestPrice(ctx context.Context, symbol string, price float64) error { + ret := _m.Called(ctx, symbol, price) + + if len(ret) == 0 { + panic("no return value specified for SetLatestPrice") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, float64) error); ok { + r0 = rf(ctx, symbol, price) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // SubtractFinalityProviderStats provides a mock function with given fields: ctx, stakingTxHashHex, fpPkHexes, amount func (_m *V2DBClient) SubtractFinalityProviderStats(ctx context.Context, stakingTxHashHex string, fpPkHexes []string, amount uint64) error { ret := _m.Called(ctx, stakingTxHashHex, fpPkHexes, amount)