diff --git a/assets/coingecko.json b/assets/coingecko.json index 23dae20..6791731 100644 --- a/assets/coingecko.json +++ b/assets/coingecko.json @@ -1 +1 @@ -{"cosmos":{"usd":6.71}} \ No newline at end of file +{"cosmos":{"usd":6.71},"test": {}} \ No newline at end of file diff --git a/pkg/app.go b/pkg/app.go index 87b5cef..595d80b 100644 --- a/pkg/app.go +++ b/pkg/app.go @@ -5,7 +5,6 @@ import ( fetchersPkg "main/pkg/fetchers" "main/pkg/fs" generatorsPkg "main/pkg/generators" - coingeckoPkg "main/pkg/price_fetchers/coingecko" statePkg "main/pkg/state" "main/pkg/tendermint" "main/pkg/tracing" @@ -69,7 +68,6 @@ func NewApp(configPath string, filesystem fs.FS, version string) *App { } tracer := tracing.InitTracer(appConfig.TracingConfig, version) - coingecko := coingeckoPkg.NewCoingecko(appConfig, logger, tracer) rpcs := make(map[string]*tendermint.RPCWithConsumers, len(appConfig.Chains)) @@ -90,7 +88,7 @@ func NewApp(configPath string, filesystem fs.FS, version string) *App { fetchersPkg.NewValidatorsFetcher(logger, appConfig.Chains, rpcs, tracer), fetchersPkg.NewConsumerValidatorsFetcher(logger, appConfig.Chains, rpcs, tracer), fetchersPkg.NewStakingParamsFetcher(logger, appConfig.Chains, rpcs, tracer), - fetchersPkg.NewPriceFetcher(logger, appConfig, tracer, coingecko), + fetchersPkg.NewPriceFetcher(logger, appConfig, tracer), fetchersPkg.NewNodeInfoFetcher(logger, appConfig.Chains, rpcs, tracer), fetchersPkg.NewConsumerInfoFetcher(logger, appConfig.Chains, rpcs, tracer), fetchersPkg.NewValidatorConsumersFetcher(logger, appConfig.Chains, rpcs, tracer), diff --git a/pkg/config/config.go b/pkg/config/config.go index 4f371f5..618bf01 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -50,28 +50,6 @@ func (c *Config) DisplayWarnings() []Warning { return warnings } -func (c *Config) GetCoingeckoCurrencies() []string { - currencies := []string{} - - for _, chain := range c.Chains { - for _, denom := range chain.Denoms { - if denom.CoingeckoCurrency != "" { - currencies = append(currencies, denom.CoingeckoCurrency) - } - } - - for _, consumerChain := range chain.ConsumerChains { - for _, denom := range consumerChain.Denoms { - if denom.CoingeckoCurrency != "" { - currencies = append(currencies, denom.CoingeckoCurrency) - } - } - } - } - - return currencies -} - func GetConfig(path string, filesystem fs.FS) (*Config, error) { configBytes, err := filesystem.ReadFile(path) if err != nil { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c06a8a2..dc59db0 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -88,30 +88,6 @@ func TestDisplayWarningsEmpty(t *testing.T) { require.Empty(t, warnings) } -func TestCoingeckoCurrencies(t *testing.T) { - t.Parallel() - - config := Config{ - Chains: []*Chain{{ - Denoms: DenomInfos{ - {Denom: "denom1", CoingeckoCurrency: "denom1"}, - {Denom: "denom2"}, - }, - ConsumerChains: []*ConsumerChain{{ - Denoms: DenomInfos{ - {Denom: "denom3", CoingeckoCurrency: "denom3"}, - {Denom: "denom4"}, - }, - }}, - }}, - } - - currencies := config.GetCoingeckoCurrencies() - require.Len(t, currencies, 2) - require.Contains(t, currencies, "denom1") - require.Contains(t, currencies, "denom3") -} - func TestLoadConfigNotFound(t *testing.T) { t.Parallel() diff --git a/pkg/config/denom_info.go b/pkg/config/denom_info.go index 0bf5346..1c31b89 100644 --- a/pkg/config/denom_info.go +++ b/pkg/config/denom_info.go @@ -2,6 +2,7 @@ package config import ( "errors" + "main/pkg/constants" "main/pkg/types" "math" @@ -43,6 +44,14 @@ func (d *DenomInfo) DisplayWarnings(chain *Chain) []Warning { return warnings } +func (d *DenomInfo) PriceFetchers() []constants.PriceFetcherName { + if d.CoingeckoCurrency != "" { + return []constants.PriceFetcherName{constants.PriceFetcherNameCoingecko} + } + + return []constants.PriceFetcherName{} +} + type DenomInfos []*DenomInfo func (d DenomInfos) Find(denom string) *DenomInfo { diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 5dcc1f1..f822391 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -2,6 +2,8 @@ package constants type FetcherName string +type PriceFetcherName string + const ( FetcherNameSlashingParams FetcherName = "slashing-params" FetcherNameSoftOptOutThreshold FetcherName = "soft-opt-out-threshold" @@ -29,4 +31,8 @@ const ( ValidatorStatusBonded = "BOND_STATUS_BONDED" HeaderBlockHeight = "Grpc-Metadata-X-Cosmos-Block-Height" + + CoingeckoBaseCurrency string = "usd" + + PriceFetcherNameCoingecko PriceFetcherName = "coingecko" ) diff --git a/pkg/fetchers/price.go b/pkg/fetchers/price.go index 7ad1fca..9bd5937 100644 --- a/pkg/fetchers/price.go +++ b/pkg/fetchers/price.go @@ -4,81 +4,116 @@ import ( "context" "main/pkg/config" "main/pkg/constants" + "main/pkg/price_fetchers" coingeckoPkg "main/pkg/price_fetchers/coingecko" "main/pkg/types" + "sync" "github.com/rs/zerolog" "go.opentelemetry.io/otel/trace" ) type PriceFetcher struct { - Logger zerolog.Logger - Config *config.Config - Tracer trace.Tracer - Coingecko *coingeckoPkg.Coingecko + Logger zerolog.Logger + Config *config.Config + Tracer trace.Tracer - CurrenciesRatesToChains map[string]map[string]float64 + Fetchers map[constants.PriceFetcherName]price_fetchers.PriceFetcher +} + +type PriceInfo struct { + Source constants.PriceFetcherName + BaseCurrency string + Value float64 } type PriceData struct { - Prices map[string]map[string]float64 + Prices map[string]map[string]PriceInfo } func NewPriceFetcher( logger *zerolog.Logger, config *config.Config, tracer trace.Tracer, - coingecko *coingeckoPkg.Coingecko, ) *PriceFetcher { + fetchers := map[constants.PriceFetcherName]price_fetchers.PriceFetcher{ + constants.PriceFetcherNameCoingecko: coingeckoPkg.NewCoingecko(config, logger, tracer), + } + return &PriceFetcher{ - Logger: logger.With().Str("component", "price_fetcher").Logger(), - Config: config, - Tracer: tracer, - Coingecko: coingecko, + Logger: logger.With().Str("component", "price_fetcher").Logger(), + Config: config, + Tracer: tracer, + Fetchers: fetchers, } } func (q *PriceFetcher) Fetch( ctx context.Context, ) (interface{}, []*types.QueryInfo) { - currenciesList := q.Config.GetCoingeckoCurrencies() - - var currenciesRates map[string]float64 - var currenciesQuery *types.QueryInfo + queries := []*types.QueryInfo{} + denomsByPriceFetcher := map[constants.PriceFetcherName][]price_fetchers.ChainWithDenom{} - var queries []*types.QueryInfo + for _, chain := range q.Config.Chains { + for _, denom := range chain.Denoms { + for _, priceFetcher := range denom.PriceFetchers() { + denomsByPriceFetcher[priceFetcher] = append(denomsByPriceFetcher[priceFetcher], price_fetchers.ChainWithDenom{ + Chain: chain.Name, + DenomInfo: denom, + }) + } + } - if len(currenciesList) > 0 { - currenciesRates, currenciesQuery = q.Coingecko.FetchPrices(currenciesList, ctx) + for _, consumer := range chain.ConsumerChains { + for _, denom := range consumer.Denoms { + for _, priceFetcher := range denom.PriceFetchers() { + denomsByPriceFetcher[priceFetcher] = append(denomsByPriceFetcher[priceFetcher], price_fetchers.ChainWithDenom{ + Chain: consumer.Name, + DenomInfo: denom, + }) + } + } + } } - if currenciesQuery != nil { - queries = append(queries, currenciesQuery) - } + var wg sync.WaitGroup + var mutex sync.Mutex + var denomsPrices = map[constants.PriceFetcherName][]price_fetchers.PriceInfo{} - q.CurrenciesRatesToChains = map[string]map[string]float64{} + for priceFetcher, denoms := range denomsByPriceFetcher { + wg.Add(1) - for _, chain := range q.Config.Chains { - q.CurrenciesRatesToChains[chain.Name] = make(map[string]float64) - q.ProcessDenoms(chain.Name, chain.Denoms, currenciesRates) + go func(priceFetcher constants.PriceFetcherName, denoms []price_fetchers.ChainWithDenom) { + defer wg.Done() - for _, consumer := range chain.ConsumerChains { - q.CurrenciesRatesToChains[consumer.Name] = make(map[string]float64) - q.ProcessDenoms(consumer.Name, consumer.Denoms, currenciesRates) - } + priceFetcherDenoms, priceFetcherQuery := q.Fetchers[priceFetcher].FetchPrices(denoms, ctx) + + mutex.Lock() + queries = append(queries, priceFetcherQuery) + denomsPrices[priceFetcher] = priceFetcherDenoms + mutex.Unlock() + }(priceFetcher, denoms) } - return PriceData{Prices: q.CurrenciesRatesToChains}, queries -} + wg.Wait() + + prices := map[string]map[string]PriceInfo{} -func (q *PriceFetcher) ProcessDenoms(chainName string, denoms config.DenomInfos, currenciesRates map[string]float64) { - for _, denom := range denoms { - // using coingecko response - if rate, ok := currenciesRates[denom.CoingeckoCurrency]; ok { - q.CurrenciesRatesToChains[chainName][denom.DisplayDenom] = rate - continue + for priceFetcher, denomInfos := range denomsPrices { + for _, denomInfo := range denomInfos { + if _, ok := prices[denomInfo.Chain]; !ok { + prices[denomInfo.Chain] = map[string]PriceInfo{} + } + + prices[denomInfo.Chain][denomInfo.Denom] = PriceInfo{ + Source: priceFetcher, + BaseCurrency: denomInfo.BaseCurrency, + Value: denomInfo.Price, + } } } + + return PriceData{Prices: prices}, queries } func (q *PriceFetcher) Name() constants.FetcherName { diff --git a/pkg/fetchers/price_test.go b/pkg/fetchers/price_test.go index 4a06b4b..c3bdae7 100644 --- a/pkg/fetchers/price_test.go +++ b/pkg/fetchers/price_test.go @@ -7,7 +7,6 @@ import ( configPkg "main/pkg/config" "main/pkg/constants" loggerPkg "main/pkg/logger" - coingeckoPkg "main/pkg/price_fetchers/coingecko" "main/pkg/tracing" "testing" @@ -25,13 +24,11 @@ func TestPriceFetcherBase(t *testing.T) { config := &configPkg.Config{Chains: chains} logger := loggerPkg.GetNopLogger() tracer := tracing.InitNoopTracer() - coingecko := coingeckoPkg.NewCoingecko(config, logger, tracer) fetcher := NewPriceFetcher( logger, config, tracer, - coingecko, ) assert.NotNil(t, fetcher) @@ -56,13 +53,11 @@ func TestPriceFetcherProviderCoingeckoError(t *testing.T) { config := &configPkg.Config{Chains: chains} logger := loggerPkg.GetNopLogger() tracer := tracing.InitNoopTracer() - coingecko := coingeckoPkg.NewCoingecko(config, logger, tracer) fetcher := NewPriceFetcher( logger, config, tracer, - coingecko, ) data, queries := fetcher.Fetch(context.Background()) assert.Len(t, queries, 1) @@ -70,10 +65,7 @@ func TestPriceFetcherProviderCoingeckoError(t *testing.T) { balanceData, ok := data.(PriceData) assert.True(t, ok) - - chainData, ok := balanceData.Prices["chain"] - assert.True(t, ok) - assert.Empty(t, chainData) + assert.Empty(t, balanceData.Prices) } //nolint:paralleltest // disabled due to httpmock usage @@ -94,13 +86,11 @@ func TestPriceFetcherProviderCoingeckoSuccess(t *testing.T) { config := &configPkg.Config{Chains: chains} logger := loggerPkg.GetNopLogger() tracer := tracing.InitNoopTracer() - coingecko := coingeckoPkg.NewCoingecko(config, logger, tracer) fetcher := NewPriceFetcher( logger, config, tracer, - coingecko, ) data, queries := fetcher.Fetch(context.Background()) assert.Len(t, queries, 1) @@ -114,7 +104,9 @@ func TestPriceFetcherProviderCoingeckoSuccess(t *testing.T) { denomData, ok := chainData["atom"] assert.True(t, ok) - assert.InEpsilon(t, 6.71, denomData, 0.01) + assert.InEpsilon(t, 6.71, denomData.Value, 0.01) + assert.Equal(t, constants.CoingeckoBaseCurrency, denomData.BaseCurrency) + assert.Equal(t, constants.PriceFetcherNameCoingecko, denomData.Source) } //nolint:paralleltest // disabled due to httpmock usage @@ -124,27 +116,29 @@ func TestPriceFetcherConsumerCoingeckoSuccess(t *testing.T) { httpmock.RegisterResponder( "GET", - "https://api.coingecko.com/api/v3/simple/price?ids=cosmos&vs_currencies=usd", + "https://api.coingecko.com/api/v3/simple/price?ids=cosmos,test&vs_currencies=usd", httpmock.NewBytesResponder(200, assets.GetBytesOrPanic("coingecko.json")), ) chains := []*configPkg.Chain{{ Name: "chain", ConsumerChains: []*configPkg.ConsumerChain{{ - Name: "consumer", - Denoms: configPkg.DenomInfos{{Denom: "uatom", DisplayDenom: "atom", CoingeckoCurrency: "cosmos"}}, + Name: "consumer", + Denoms: configPkg.DenomInfos{ + {Denom: "uatom", DisplayDenom: "atom", CoingeckoCurrency: "cosmos"}, + {Denom: "utest", DisplayDenom: "test", CoingeckoCurrency: "test"}, + {Denom: "ustake", DisplayDenom: "stake"}, + }, }}, }} config := &configPkg.Config{Chains: chains} logger := loggerPkg.GetNopLogger() tracer := tracing.InitNoopTracer() - coingecko := coingeckoPkg.NewCoingecko(config, logger, tracer) fetcher := NewPriceFetcher( logger, config, tracer, - coingecko, ) data, queries := fetcher.Fetch(context.Background()) assert.Len(t, queries, 1) @@ -158,5 +152,7 @@ func TestPriceFetcherConsumerCoingeckoSuccess(t *testing.T) { denomData, ok := chainData["atom"] assert.True(t, ok) - assert.InEpsilon(t, 6.71, denomData, 0.01) + assert.InEpsilon(t, 6.71, denomData.Value, 0.01) + assert.Equal(t, constants.CoingeckoBaseCurrency, denomData.BaseCurrency) + assert.Equal(t, constants.PriceFetcherNameCoingecko, denomData.Source) } diff --git a/pkg/generators/price.go b/pkg/generators/price.go index 18a1b7c..2c51b65 100644 --- a/pkg/generators/price.go +++ b/pkg/generators/price.go @@ -26,7 +26,7 @@ func (g *PriceGenerator) Generate(state *statePkg.State) []prometheus.Collector Name: constants.MetricsPrefix + "price", Help: "Price of 1 token in display denom in USD", }, - []string{"chain", "denom"}, + []string{"chain", "denom", "source", "base_currency"}, ) data, _ := dataRaw.(fetchersPkg.PriceData) @@ -34,9 +34,11 @@ func (g *PriceGenerator) Generate(state *statePkg.State) []prometheus.Collector for chainName, chainPrices := range data.Prices { for denom, price := range chainPrices { tokenPriceGauge.With(prometheus.Labels{ - "chain": chainName, - "denom": denom, - }).Set(price) + "chain": chainName, + "denom": denom, + "source": string(price.Source), + "base_currency": price.BaseCurrency, + }).Set(price.Value) } } diff --git a/pkg/generators/price_test.go b/pkg/generators/price_test.go index f541359..b385c8e 100644 --- a/pkg/generators/price_test.go +++ b/pkg/generators/price_test.go @@ -26,9 +26,13 @@ func TestPriceGeneratorNotEmptyState(t *testing.T) { state := statePkg.NewState() state.Set(constants.FetcherNamePrice, fetchers.PriceData{ - Prices: map[string]map[string]float64{ + Prices: map[string]map[string]fetchers.PriceInfo{ "chain": { - "denom": 0.01, + "denom": fetchers.PriceInfo{ + Value: 0.01, + Source: constants.PriceFetcherNameCoingecko, + BaseCurrency: constants.CoingeckoBaseCurrency, + }, }, }, }) @@ -40,7 +44,9 @@ func TestPriceGeneratorNotEmptyState(t *testing.T) { gauge, ok := results[0].(*prometheus.GaugeVec) assert.True(t, ok) assert.InEpsilon(t, 0.01, testutil.ToFloat64(gauge.With(prometheus.Labels{ - "chain": "chain", - "denom": "denom", + "chain": "chain", + "denom": "denom", + "source": string(constants.PriceFetcherNameCoingecko), + "base_currency": constants.CoingeckoBaseCurrency, })), 0.01) } diff --git a/pkg/price_fetchers/coingecko/coingecko.go b/pkg/price_fetchers/coingecko/coingecko.go index 8bd7180..e2aa8e5 100644 --- a/pkg/price_fetchers/coingecko/coingecko.go +++ b/pkg/price_fetchers/coingecko/coingecko.go @@ -4,8 +4,11 @@ import ( "context" "fmt" "main/pkg/config" + "main/pkg/constants" "main/pkg/http" + "main/pkg/price_fetchers" "main/pkg/types" + "main/pkg/utils" "strings" "go.opentelemetry.io/otel/trace" @@ -36,17 +39,25 @@ func NewCoingecko( } func (c *Coingecko) FetchPrices( - currencies []string, + denoms []price_fetchers.ChainWithDenom, ctx context.Context, -) (map[string]float64, *types.QueryInfo) { +) ([]price_fetchers.PriceInfo, *types.QueryInfo) { childCtx, querierSpan := c.Tracer.Start( ctx, "Fetching Coingecko prices", ) defer querierSpan.End() + currencies := utils.Map(denoms, func(c price_fetchers.ChainWithDenom) string { + return c.DenomInfo.CoingeckoCurrency + }) + ids := strings.Join(currencies, ",") - url := fmt.Sprintf("https://api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=usd", ids) + url := fmt.Sprintf( + "https://api.coingecko.com/api/v3/simple/price?ids=%s&vs_currencies=%s", + ids, + constants.CoingeckoBaseCurrency, + ) var response Response queryInfo, _, err := c.Client.Get(url, &response, types.HTTPPredicateAlwaysPass(), childCtx) @@ -57,13 +68,26 @@ func (c *Coingecko) FetchPrices( return nil, &queryInfo } - prices := map[string]float64{} + pricesInfo := []price_fetchers.PriceInfo{} - for currencyKey, currencyValue := range response { - for _, baseCurrencyValue := range currencyValue { - prices[currencyKey] = baseCurrencyValue + for _, denom := range denoms { + currency, ok := response[denom.DenomInfo.CoingeckoCurrency] + if !ok { + continue } + + value, ok := currency[constants.CoingeckoBaseCurrency] + if !ok { + continue + } + + pricesInfo = append(pricesInfo, price_fetchers.PriceInfo{ + Chain: denom.Chain, + Denom: denom.DenomInfo.DisplayDenom, + BaseCurrency: constants.CoingeckoBaseCurrency, + Price: value, + }) } - return prices, &queryInfo + return pricesInfo, &queryInfo } diff --git a/pkg/price_fetchers/price_fetcher.go b/pkg/price_fetchers/price_fetcher.go new file mode 100644 index 0000000..1e02a59 --- /dev/null +++ b/pkg/price_fetchers/price_fetcher.go @@ -0,0 +1,23 @@ +package price_fetchers + +import ( + "context" + configPkg "main/pkg/config" + "main/pkg/types" +) + +type ChainWithDenom struct { + Chain string + DenomInfo *configPkg.DenomInfo +} + +type PriceInfo struct { + Chain string + Denom string + BaseCurrency string + Price float64 +} + +type PriceFetcher interface { + FetchPrices(denoms []ChainWithDenom, ctx context.Context) ([]PriceInfo, *types.QueryInfo) +}