diff --git a/feeder/priceprovider/aggregateprovider.go b/feeder/priceprovider/aggregateprovider.go index 3355ce0..0b97acc 100644 --- a/feeder/priceprovider/aggregateprovider.go +++ b/feeder/priceprovider/aggregateprovider.go @@ -22,13 +22,13 @@ type AggregatePriceProvider struct { // given multiple PriceProvider. func NewAggregatePriceProvider( sourcesToPairSymbolMap map[string]map[asset.Pair]types.Symbol, - configToMap map[string]json.RawMessage, + sourceConfigMap map[string]json.RawMessage, logger zerolog.Logger, ) types.PriceProvider { providers := make(map[int]types.PriceProvider, len(sourcesToPairSymbolMap)) i := 0 for sourceName, pairToSymbolMap := range sourcesToPairSymbolMap { - providers[i] = NewPriceProvider(sourceName, pairToSymbolMap, configToMap[sourceName], logger) + providers[i] = NewPriceProvider(sourceName, pairToSymbolMap, sourceConfigMap[sourceName], logger) i++ } @@ -42,7 +42,6 @@ func NewAggregatePriceProvider( // Iteration is exhaustive and random. // If no correct PriceResponse is found, then an invalid PriceResponse is returned. func (a AggregatePriceProvider) GetPrice(pair asset.Pair) types.Price { - // Temporarily treat NUSD as perfectly pegged to the US fiat dollar // TODO(k-yang): add the NUSD pricefeed once it's available on exchanges if pair.Equal(asset.Registry.Pair(denoms.NUSD, denoms.USD)) { diff --git a/feeder/priceprovider/priceprovider.go b/feeder/priceprovider/priceprovider.go index f55eb4f..9e3d96d 100644 --- a/feeder/priceprovider/priceprovider.go +++ b/feeder/priceprovider/priceprovider.go @@ -44,6 +44,8 @@ func NewPriceProvider( source = sources.NewTickSource(symbolsFromPairToSymbolMapping(pairToSymbolMap), sources.BinancePriceUpdate, logger) case sources.Coingecko: source = sources.NewTickSource(symbolsFromPairToSymbolMapping(pairToSymbolMap), sources.CoingeckoPriceUpdate(config), logger) + case sources.CoinMarketCap: + source = sources.NewTickSource(symbolsFromPairToSymbolMapping(pairToSymbolMap), sources.CoinmarketcapPriceUpdate(config), logger) default: panic("unknown price provider: " + sourceName) } diff --git a/feeder/priceprovider/sources/coinmarketcap.go b/feeder/priceprovider/sources/coinmarketcap.go new file mode 100644 index 0000000..5dbbd02 --- /dev/null +++ b/feeder/priceprovider/sources/coinmarketcap.go @@ -0,0 +1,135 @@ +// Credit: @oleksandrmarkelov https://github.com/NibiruChain/pricefeeder/pull/27 +package sources + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + + "github.com/NibiruChain/nibiru/x/common/set" + "github.com/NibiruChain/pricefeeder/types" +) + +const ( + CoinMarketCap = "coinmarketcap" + link = "https://pro-api.coinmarketcap.com/v2/cryptocurrency/quotes/latest" + apiKeyHeaderParam = "X-CMC_PRO_API_KEY" +) + +type CmcQuotePrice struct { + Price float64 +} + +type CmcQuote struct { + USD CmcQuotePrice +} + +type CmcTicker struct { + Slug string + Quote CmcQuote +} + +type CmcResponse struct { + Data map[string]CmcTicker +} + +type CoinmarketcapConfig struct { + ApiKey string `json:"api_key"` +} + +func CoinmarketcapPriceUpdate(rawConfig json.RawMessage) types.FetchPricesFunc { + return func(symbols set.Set[types.Symbol]) (map[types.Symbol]float64, error) { + config, err := getConfig(rawConfig) + if err != nil { + return nil, err + } + + req, err := buildReq(symbols, config) + if err != nil { + return nil, err + } + + client := &http.Client{} + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + response, err := io.ReadAll(res.Body) + if err != nil { + return nil, err + } + + rawPrices, err := getPricesFromResponse(symbols, response) + if err != nil { + return nil, err + } + + return rawPrices, nil + } +} + +// extractConfig tries to get the configuration, if nothing is found, it returns an empty config. +func getConfig(jsonConfig json.RawMessage) (*CoinmarketcapConfig, error) { + c := &CoinmarketcapConfig{} + if len(jsonConfig) > 0 { + err := json.Unmarshal(jsonConfig, c) + if err != nil { + return nil, fmt.Errorf("invalid coinmarketcap config: %w", err) + } + } + return c, nil +} + +func getPricesFromResponse(symbols set.Set[types.Symbol], response []byte) (map[types.Symbol]float64, error) { + var respCmc CmcResponse + err := json.Unmarshal(response, &respCmc) + if err != nil { + return nil, err + } + + cmcPrice := make(map[string]float64) + for _, value := range respCmc.Data { + cmcPrice[value.Slug] = value.Quote.USD.Price + } + + rawPrices := make(map[types.Symbol]float64) + for symbol := range symbols { + if price, ok := cmcPrice[string(symbol)]; ok { + rawPrices[symbol] = price + } else { + return nil, fmt.Errorf("symbol %s not found in response: %s\n", symbol, response) + } + } + + return rawPrices, err +} + +func buildReq(symbols set.Set[types.Symbol], c *CoinmarketcapConfig) (*http.Request, error) { + req, err := http.NewRequest("GET", link, nil) + if err != nil { + return nil, fmt.Errorf("Can not create a request with link: %s\n", link) + } + + params := url.Values{} + params.Add("slug", coinmarketcapSymbolCsv(symbols)) + + req.Header.Set("Accepts", "application/json") + req.Header.Add(apiKeyHeaderParam, c.ApiKey) + req.URL.RawQuery = params.Encode() + + return req, nil +} + +// coinmarketcapSymbolCsv returns the symbols as a comma separated string. +func coinmarketcapSymbolCsv(symbols set.Set[types.Symbol]) string { + s := "" + for symbol := range symbols { + s += string(symbol) + "," + } + + return s[:len(s)-1] +} diff --git a/feeder/priceprovider/sources/coinmarketcap_test.go b/feeder/priceprovider/sources/coinmarketcap_test.go new file mode 100644 index 0000000..c8a1189 --- /dev/null +++ b/feeder/priceprovider/sources/coinmarketcap_test.go @@ -0,0 +1,34 @@ +package sources + +import ( + "encoding/json" + "testing" + + "github.com/NibiruChain/nibiru/x/common/set" + "github.com/NibiruChain/pricefeeder/types" + "github.com/jarcoal/httpmock" + "github.com/stretchr/testify/require" +) + +func TestCoinmarketcapPriceUpdate(t *testing.T) { + httpmock.Activate() + defer httpmock.DeactivateAndReset() + + t.Run("success", func(t *testing.T) { + httpmock.RegisterResponder( + "GET", link+"?slug=bitcoin%2Cethereum", + httpmock.NewStringResponder(200, "{\"status\": {\"error_code\":0},\"data\":{\"1\":{\"slug\":\"bitcoin\",\"quote\":{\"USD\":{\"price\":23829}}}, \"100\":{\"slug\":\"ethereum\",\"quote\":{\"USD\":{\"price\":1676.85}}}}}"), + ) + rawPrices, err := CoinmarketcapPriceUpdate(json.RawMessage{})( + set.New[types.Symbol]( + "bitcoin", + "ethereum", + ), + ) + require.NoError(t, err) + + require.Equal(t, 2, len(rawPrices)) + require.Equal(t, rawPrices["bitcoin"], 23829.0) + require.Equal(t, rawPrices["ethereum"], 1676.85) + }) +}