Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: return btc price in stats endpoint #167

Merged
merged 19 commits into from
Dec 4, 2024
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: 3s # 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: 3s # mongodb ttl
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
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"

"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{}).Decode(&btcPrice)
if err != nil {
return nil, err
}

return &btcPrice, nil
}

func (db *Database) SetBtcPrice(ctx context.Context, price float64) error {
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
client := db.Client.Database(db.DbName).Collection(model.BtcPriceCollection)

// Always store as a single document
_, err := client.DeleteMany(ctx, bson.M{})
if err != nil {
return err
}

btcPrice := model.BtcPrice{
Price: price,
CreatedAt: time.Now(),
}

_, err = client.InsertOne(ctx, btcPrice)
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
8 changes: 8 additions & 0 deletions internal/db/model/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package model

import "time"

type BtcPrice struct {
Price float64 `bson:"price"`
CreatedAt time.Time `bson:"created_at"` // TTL index will be on this field
}
22 changes: 22 additions & 0 deletions internal/db/model/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 +82,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 +132,16 @@ 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)

// Create TTL index with expiration
index := mongo.IndexModel{
Keys: bson.D{{Key: "created_at", Value: 1}},
Options: options.Index().SetExpireAfterSeconds(int32(cacheTTL.Seconds())), // TTL from config
}

_, err := collection.Indexes().CreateOne(ctx, index)
return err
}
118 changes: 118 additions & 0 deletions internal/services/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package services

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"

"github.com/rs/zerolog/log"
"go.mongodb.org/mongo-driver/mongo"
)

// Response structures - only include what we need
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 (s *Services) GetLatestBtcPriceUsd(ctx context.Context) (float64, error) {
// Try to get price from MongoDB first
btcPrice, err := s.DbClient.GetLatestBtcPrice(ctx)
if err != nil {
if errors.Is(err, mongo.ErrNoDocuments) {
// Document not found, fetch from CoinMarketCap
price, err := s.fetchPriceFromCoinMarketCap(ctx)
if err != nil {
return 0, fmt.Errorf("failed to fetch price from CoinMarketCap: %w", err)
}

// Store in MongoDB with TTL
if err := s.DbClient.SetBtcPrice(ctx, 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 btcPrice.Price, nil
}

func (s *Services) fetchPriceFromCoinMarketCap(ctx context.Context) (float64, error) {
gusin13 marked this conversation as resolved.
Show resolved Hide resolved
logger := log.Ctx(ctx)

// Create request
req, err := http.NewRequestWithContext(ctx, "GET",
fmt.Sprintf("%s/cryptocurrency/quotes/latest", s.cfg.ExternalAPIs.CoinMarketCap.BaseURL),
nil)
if err != nil {
return 0, fmt.Errorf("failed to create request: %w", err)
}

// Add headers
req.Header.Set("X-CMC_PRO_API_KEY", s.cfg.ExternalAPIs.CoinMarketCap.APIKey)
req.Header.Set("Accept", "application/json")

// Add query parameters
q := req.URL.Query()
q.Add("symbol", "BTC")
req.URL.RawQuery = q.Encode()

logger.Debug().
Str("url", req.URL.String()).
Msg("making request to CoinMarketCap")

// Create HTTP client with timeout
client := &http.Client{
Timeout: s.cfg.ExternalAPIs.CoinMarketCap.Timeout,
}

// Make the actual HTTP request using the client
resp, err := client.Do(req)
if err != nil {
return 0, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()

// Read response body
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, fmt.Errorf("failed to read response body: %w", err)
}

// Check status code
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body))
}

// Parse response
var cmcResp CMCResponse
if err := json.Unmarshal(body, &cmcResp); err != nil {
return 0, fmt.Errorf("failed to parse response: %w", err)
}

// Extract BTC price in USD
btcData, exists := cmcResp.Data["BTC"]
if !exists {
return 0, fmt.Errorf("BTC data not found in response")
}

usdQuote, exists := btcData.Quote["USD"]
if !exists {
return 0, fmt.Errorf("USD quote not found in response")
}

return usdQuote.Price, nil
}
29 changes: 22 additions & 7 deletions internal/services/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package services
import (
"context"
"fmt"
"math"
"net/http"

"github.com/babylonlabs-io/staking-api-service/internal/db"
Expand All @@ -11,13 +12,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 {
Expand Down Expand Up @@ -184,6 +186,18 @@ func (s *Services) GetOverallStats(
pendingTvl = unconfirmedTvl - confirmedTvl
}

// Only fetch BTC price if ExternalAPIs are configured
var btcPrice *float64
if s.cfg.ExternalAPIs != nil {
price, err := s.GetLatestBtcPriceUsd(ctx)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("error while fetching latest btc price")
return nil, types.NewInternalServiceError(err)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not fail the whole request due to this. We should emit a metric and have alert based on this, but silently fail by fall back to empty value for the additional field we added in the stats response.
FE will handle it gracefully.

}
roundedPrice := math.Round(price*100) / 100
btcPrice = &roundedPrice
}

return &OverallStatsPublic{
ActiveTvl: int64(confirmedTvl),
TotalTvl: stats.TotalTvl,
Expand All @@ -192,6 +206,7 @@ func (s *Services) GetOverallStats(
TotalStakers: stats.TotalStakers,
UnconfirmedTvl: unconfirmedTvl,
PendingTvl: pendingTvl,
BtcPriceUsd: btcPrice,
}, nil
}

Expand Down
Loading
Loading