Skip to content

Commit

Permalink
feat: return btc price in stats endpoint (#167)
Browse files Browse the repository at this point in the history
  • Loading branch information
gusin13 authored Dec 4, 2024
1 parent 854d547 commit d45c0c0
Show file tree
Hide file tree
Showing 18 changed files with 556 additions and 9 deletions.
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ run-unprocessed-events-replay-local:
generate-mock-interface:
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/coinmarketcap && mockery --name=CoinMarketCapClientInterface --output=../../../tests/mocks --outpkg=mocks --filename=mock_coinmarketcap_client.go

test:
./bin/local-startup.sh;
Expand Down
6 changes: 6 additions & 0 deletions config/config-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ assets:
timeout: 1000
terms_acceptance_logging:
enabled: true
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
6 changes: 6 additions & 0 deletions config/config-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ assets:
timeout: 5000
terms_acceptance_logging:
enabled: true
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
12 changes: 10 additions & 2 deletions internal/clients/clients.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package clients

import (
"github.com/babylonlabs-io/staking-api-service/internal/clients/coinmarketcap"
"github.com/babylonlabs-io/staking-api-service/internal/clients/ordinals"
"github.com/babylonlabs-io/staking-api-service/internal/config"
)

type Clients struct {
Ordinals ordinals.OrdinalsClientInterface
Ordinals ordinals.OrdinalsClientInterface
CoinMarketCap coinmarketcap.CoinMarketCapClientInterface
}

func New(cfg *config.Config) *Clients {
Expand All @@ -16,7 +18,13 @@ func New(cfg *config.Config) *Clients {
ordinalsClient = ordinals.NewOrdinalsClient(cfg.Assets.Ordinals)
}

var coinMarketCapClient *coinmarketcap.CoinMarketCapClient
if cfg.ExternalAPIs != nil && cfg.ExternalAPIs.CoinMarketCap != nil {
coinMarketCapClient = coinmarketcap.NewCoinMarketCapClient(cfg.ExternalAPIs.CoinMarketCap)
}

return &Clients{
Ordinals: ordinalsClient,
Ordinals: ordinalsClient,
CoinMarketCap: coinMarketCapClient,
}
}
99 changes: 99 additions & 0 deletions internal/clients/coinmarketcap/coinmarketcap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package coinmarketcap

import (
"context"
"net/http"

baseclient "github.com/babylonlabs-io/staking-api-service/internal/clients/base"
"github.com/babylonlabs-io/staking-api-service/internal/config"
"github.com/babylonlabs-io/staking-api-service/internal/types"
)

type CoinMarketCapClient struct {
config *config.CoinMarketCapConfig
defaultHeaders map[string]string
httpClient *http.Client
}

type CMCResponse struct {
Data map[string]CryptoData `json:"data"`
}

type CryptoData struct {
Quote map[string]QuoteData `json:"quote"`
}

type QuoteData struct {
Price float64 `json:"price"`
}

func NewCoinMarketCapClient(config *config.CoinMarketCapConfig) *CoinMarketCapClient {
// Client is disabled if config is nil
if config == nil {
return nil
}

httpClient := &http.Client{}
headers := map[string]string{
"X-CMC_PRO_API_KEY": config.APIKey,
"Accept": "application/json",
}

return &CoinMarketCapClient{
config,
headers,
httpClient,
}
}

// Necessary for the BaseClient interface
func (c *CoinMarketCapClient) GetBaseURL() string {
return c.config.BaseURL
}

func (c *CoinMarketCapClient) GetDefaultRequestTimeout() int {
return int(c.config.Timeout.Milliseconds())
}

func (c *CoinMarketCapClient) GetHttpClient() *http.Client {
return c.httpClient
}

func (c *CoinMarketCapClient) GetLatestBtcPrice(ctx context.Context) (float64, *types.Error) {
path := "/cryptocurrency/quotes/latest"

opts := &baseclient.BaseClientOptions{
Path: path + "?symbol=BTC",
TemplatePath: path,
Headers: c.defaultHeaders,
}

// Use struct{} for input (no request body)
// Use CMCResponse for response type
response, err := baseclient.SendRequest[struct{}, CMCResponse](
ctx, c, http.MethodGet, opts, nil,
)
if err != nil {
return 0, err
}

btcData, exists := response.Data["BTC"]
if !exists {
return 0, types.NewErrorWithMsg(
http.StatusInternalServerError,
types.InternalServiceError,
"BTC data not found in response",
)
}

usdQuote, exists := btcData.Quote["USD"]
if !exists {
return 0, types.NewErrorWithMsg(
http.StatusInternalServerError,
types.InternalServiceError,
"USD quote not found in response",
)
}

return usdQuote.Price, nil
}
11 changes: 11 additions & 0 deletions internal/clients/coinmarketcap/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package coinmarketcap

import (
"context"

"github.com/babylonlabs-io/staking-api-service/internal/types"
)

type CoinMarketCapClientInterface interface {
GetLatestBtcPrice(ctx context.Context) (float64, *types.Error)
}
8 changes: 8 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type Config struct {
Metrics *MetricsConfig `mapstructure:"metrics"`
Assets *AssetsConfig `mapstructure:"assets"`
TermsAcceptanceLogging *TermsAcceptanceConfig `mapstructure:"terms_acceptance_logging"`
ExternalAPIs *ExternalAPIsConfig `mapstructure:"external_apis"`
}

func (cfg *Config) Validate() error {
Expand All @@ -42,6 +43,13 @@ func (cfg *Config) Validate() error {
}
}

// ExternalAPIs is optional
if cfg.ExternalAPIs != nil {
if err := cfg.ExternalAPIs.Validate(); err != nil {
return err
}
}

return nil
}

Expand Down
49 changes: 49 additions & 0 deletions internal/config/external_apis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
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")
}

if err := cfg.CoinMarketCap.Validate(); err != nil {
return err
}

return nil
}

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
}
40 changes: 40 additions & 0 deletions internal/db/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package db

import (
"context"
"time"

"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo/options"

"github.com/babylonlabs-io/staking-api-service/internal/db/model"
)

func (db *Database) GetLatestBtcPrice(ctx context.Context) (*model.BtcPrice, error) {
client := db.Client.Database(db.DbName).Collection(model.BtcPriceCollection)

var btcPrice model.BtcPrice
err := client.FindOne(ctx, bson.M{"_id": model.BtcPriceDocID}).Decode(&btcPrice)
if err != nil {
return nil, err
}

return &btcPrice, nil
}

func (db *Database) SetBtcPrice(ctx context.Context, price float64) error {
client := db.Client.Database(db.DbName).Collection(model.BtcPriceCollection)

btcPrice := model.BtcPrice{
ID: model.BtcPriceDocID, // Fixed ID for single document
Price: price,
CreatedAt: time.Now(), // For TTL index
}

opts := options.Update().SetUpsert(true)
filter := bson.M{"_id": model.BtcPriceDocID}
update := bson.M{"$set": btcPrice}

_, err := client.UpdateOne(ctx, filter, update, opts)
return err
}
4 changes: 4 additions & 0 deletions internal/db/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ type DBClient interface {
) (*DbResultMap[model.DelegationDocument], error)
// SaveTermsAcceptance saves the acceptance of the terms of service of the public key
SaveTermsAcceptance(ctx context.Context, termsAcceptance *model.TermsAcceptance) error
// GetLatestBtcPrice fetches the BTC price from the database.
GetLatestBtcPrice(ctx context.Context) (*model.BtcPrice, error)
// SetBtcPrice sets the latest BTC price in the database.
SetBtcPrice(ctx context.Context, price float64) error
}

type DelegationFilter struct {
Expand Down
11 changes: 11 additions & 0 deletions internal/db/model/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package model

import "time"

const BtcPriceDocID = "btc_price"

type BtcPrice struct {
ID string `bson:"_id"` // primary key, will always be "btc_price" to ensure single document
Price float64 `bson:"price"`
CreatedAt time.Time `bson:"created_at"` // TTL index will be on this field
}
35 changes: 35 additions & 0 deletions internal/db/model/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package model
import (
"context"
"fmt"
"strings"
"time"

"github.com/babylonlabs-io/staking-api-service/internal/config"
Expand All @@ -22,6 +23,7 @@ const (
TimeLockCollection = "timelock_queue"
UnbondingCollection = "unbonding_queue"
BtcInfoCollection = "btc_info"
BtcPriceCollection = "btc_price"
UnprocessableMsgCollection = "unprocessable_messages"
PkAddressMappingsCollection = "pk_address_mappings"
TermsAcceptanceCollection = "terms_acceptance"
Expand Down Expand Up @@ -81,6 +83,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
}
Expand Down Expand Up @@ -123,3 +133,28 @@ 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(BtcPriceCollection)

// 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
}
Loading

0 comments on commit d45c0c0

Please sign in to comment.