Skip to content

Commit

Permalink
Pass $ alongside TVL #489
Browse files Browse the repository at this point in the history
  • Loading branch information
kirugan committed Jan 21, 2025
1 parent 6b01fc3 commit b5e7cd7
Show file tree
Hide file tree
Showing 18 changed files with 497 additions and 18 deletions.
8 changes: 7 additions & 1 deletion config/config-docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,10 @@ assets:
ordinals:
host: "http://ord-poc.devnet.babylonchain.io"
port: 8888
timeout: 1000
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
8 changes: 7 additions & 1 deletion config/config-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,10 @@ assets:
ordinals:
host: "http://ord-poc.devnet.babylonchain.io"
port: 8888
timeout: 5000
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
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
}
7 changes: 7 additions & 0 deletions internal/shared/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ func (cfg *Config) Validate() error {
}
}

// ExternalAPIs is optional
if cfg.ExternalAPIs != nil {

Check failure on line 57 in internal/shared/config/config.go

View workflow job for this annotation

GitHub Actions / lint_test / build

cfg.ExternalAPIs undefined (type *Config has no field or method ExternalAPIs)

Check failure on line 57 in internal/shared/config/config.go

View workflow job for this annotation

GitHub Actions / lint_test / unit-tests

cfg.ExternalAPIs undefined (type *Config has no field or method ExternalAPIs)
if err := cfg.ExternalAPIs.Validate(); err != nil {

Check failure on line 58 in internal/shared/config/config.go

View workflow job for this annotation

GitHub Actions / lint_test / build

cfg.ExternalAPIs undefined (type *Config has no field or method ExternalAPIs)

Check failure on line 58 in internal/shared/config/config.go

View workflow job for this annotation

GitHub Actions / lint_test / unit-tests

cfg.ExternalAPIs undefined (type *Config has no field or method ExternalAPIs)
return err
}
}

return nil
}

Expand Down
32 changes: 32 additions & 0 deletions internal/shared/db/client/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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"
"time"
)

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
}
5 changes: 5 additions & 0 deletions internal/shared/db/client/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

// GetLatestBtcPrice fetches the BTC price from the database.
GetLatestBtcPrice(ctx context.Context) (*dbmodel.BtcPrice, error)
// SetBtcPrice sets the latest BTC price in the database.
SetBtcPrice(ctx context.Context, price float64) error
}
11 changes: 11 additions & 0 deletions internal/shared/db/model/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dbmodel

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
}
31 changes: 31 additions & 0 deletions internal/shared/db/model/setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -26,6 +27,7 @@ const (
V1UnbondingCollection = "unbonding_queue"
V1BtcInfoCollection = "btc_info"
V1UnprocessableMsgCollection = "unprocessable_messages"
BtcPriceCollection = "btc_price"
// V2
V2StatsLockCollection = "v2_stats_lock"
V2OverallStatsCollection = "v2_overall_stats"
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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(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
}
64 changes: 64 additions & 0 deletions internal/shared/http/clients/coinmarketcap/coinmarketcap.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package coinmarketcap

import (
"context"
"net/http"

"github.com/babylonlabs-io/staking-api-service/internal/config"
"github.com/babylonlabs-io/staking-api-service/internal/shared/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) {
// todo implement me
return 0, nil
}
12 changes: 12 additions & 0 deletions internal/shared/http/clients/coinmarketcap/interface.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package coinmarketcap

import (
"context"

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

//go:generate mockery --name=CoinMarketCapClientInterface --output=../../../../../tests/mocks --outpkg=mocks --filename=mock_coinmarketcap_client.go
type CoinMarketCapClientInterface interface {
GetLatestBtcPrice(ctx context.Context) (float64, *types.Error)
}
11 changes: 9 additions & 2 deletions internal/shared/http/clients/http_clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import (
)

type Clients struct {
Ordinals ordinals.OrdinalsClient
Ordinals ordinals.OrdinalsClient
CoinMarketCap coinmarketcap.CoinMarketCapClientInterface // todo for review: move to another location?
}

func New(cfg *config.Config) *Clients {
Expand All @@ -16,7 +17,13 @@ func New(cfg *config.Config) *Clients {
ordinalsClient = ordinals.New(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,
}
}
30 changes: 30 additions & 0 deletions internal/shared/services/service/btc_price.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package service

import (
"context"
"errors"
"fmt"
"go.mongodb.org/mongo-driver/mongo"
)

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.Clients.CoinMarketCap.GetLatestBtcPrice(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
}
30 changes: 23 additions & 7 deletions internal/v1/service/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,18 @@ import (
"github.com/babylonlabs-io/staking-api-service/internal/shared/db"
"github.com/babylonlabs-io/staking-api-service/internal/shared/types"
"github.com/rs/zerolog/log"
"math"
)

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,19 @@ func (s *V1Service) GetOverallStats(
pendingTvl = unconfirmedTvl - confirmedTvl
}

// Only fetch BTC price if ExternalAPIs are configured
var btcPrice *float64
if s.cfg.ExternalAPIs != nil && s.cfg.ExternalAPIs.CoinMarketCap != nil {
price, err := s.GetLatestBtcPriceUsd(ctx)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("error while fetching latest btc price")
btcPrice = nil // return empty field if error
} else {
roundedPrice := math.Round(price*100) / 100
btcPrice = &roundedPrice
}
}

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

Expand Down
Loading

0 comments on commit b5e7cd7

Please sign in to comment.