decred.org/dcrdex@v1.0.5/dex/fiatrates/sources.go (about)

     1  package fiatrates
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"net/url"
     7  	"strconv"
     8  	"strings"
     9  	"time"
    10  
    11  	"decred.org/dcrdex/dex"
    12  	"github.com/ethereum/go-ethereum/log"
    13  	"golang.org/x/text/cases"
    14  	"golang.org/x/text/language"
    15  )
    16  
    17  const (
    18  	defaultRefreshInterval = 5 * time.Minute
    19  	messariRefreshInterval = 10 * time.Minute
    20  
    21  	// cryptoCompare API request limits: 100,000 per month, capped at 250,000
    22  	// lifetime calls. Multiple tickers can be requested in a single call. We
    23  	// only exhaust 8928 calls per month if we ask every 5min, and a single API
    24  	// Key should last ~28 months or 2 years 4months.
    25  	cryptoCompare              = "CryptoCompare"
    26  	cryptoComparePriceEndpoint = "https://min-api.cryptocompare.com/data/pricemulti?fsyms=%s&tsyms=USD"
    27  
    28  	// According to the docs (See:
    29  	// https://www.binance.com/en/support/faq/frequently-asked-questions-on-api-360004492232),
    30  	// there's a 6,000 request weight per minute (keep in mind that this is not
    31  	// necessarily the same as 6,000 requests) limit for API requests. Multiple
    32  	// tickers are quested in a single call every 5min. We can never get in
    33  	// trouble for this. An HTTP 403 is returned for those that violates this
    34  	// hard rule. More information on limits can be found here:
    35  	// https://binance-docs.github.io/apidocs/spot/en/#limits
    36  	binance                = "Binance"
    37  	binancePriceEndpoint   = "https://api3.binance.com/api/v3/ticker/price?symbols=[%s]"
    38  	binanceUSPriceEndpoint = "https://api.binance.us/api/v3/ticker/price?symbols=[%s]"
    39  
    40  	// According to the docs (See:
    41  	// https://api.coinpaprika.com/#section/Rate-limit), the free version is
    42  	// eligible to 20,000 calls per month. All tickers are fetched in one call,
    43  	// that means we only exhaust 288 calls per day and 8928 calls per month if
    44  	// we request rate every 5min. Max of 2000 asset data returned and API is
    45  	// updated every 5min.
    46  	coinpaprika              = "Coinparika"
    47  	coinpaprikaPriceEndpoint = "https://api.coinpaprika.com/v1/tickers"
    48  
    49  	// According to the x-ratelimit-limit header, we can make 4000 requests
    50  	// every 24hours. The x-ratelimit-reset header tells when the next reset
    51  	// will be. See: Header values for
    52  	// https://data.messari.io/api/v1/assets/DCR/metrics/market-data. From a
    53  	// previous research by buck, say "Without an API key requests are rate
    54  	// limited to 20 requests per minute". That means we are limited to 20
    55  	// requests for tickers per minute but with with a 10min refresh interval,
    56  	// we'd only exhaust 2880 call assuming we are fetching data for 20 tickers
    57  	// (assets supported by dex are still below 20, revisit if we implement up
    58  	// to 20 assets).
    59  	messari              = "Messari"
    60  	messariPriceEndpoint = "https://data.messari.io/api/v1/assets/%s/metrics/market-data"
    61  
    62  	// According to the gw-ratelimit-limit header, we can make 2000 requests
    63  	// every 24hours(I think there's only a gw-ratelimit-reset header set to
    64  	// 30000 but can't decipher if it's in seconds or minutes). Multiple tickers
    65  	// can be requested in a single call (Firo and ZCL not supported). See
    66  	// Header values for
    67  	// https://api.kucoin.com/api/v1/prices?currencies=BTC,DCR. Requesting for
    68  	// ticker data every 5min gives us 288 calls per day, with the remaining
    69  	// 1712 calls left unused.
    70  	kuCoin              = "KuCoin"
    71  	kuCoinPriceEndpoint = "https://api.kucoin.com/api/v1/prices?currencies=%s"
    72  )
    73  
    74  var (
    75  	upperCaser = cases.Upper(language.AmericanEnglish)
    76  )
    77  
    78  func fiatSources(cfg Config) []*source {
    79  	disabledSources := strings.ToLower(cfg.DisabledFiatSources)
    80  	sources := []*source{
    81  		{
    82  			name:            cryptoCompare,
    83  			requestInterval: defaultRefreshInterval,
    84  			disabled:        cfg.CryptoCompareAPIKey == "" || strings.Contains(disabledSources, strings.ToLower(cryptoCompare)),
    85  			getRates: func(ctx context.Context, tickers []string, _ dex.Logger) (map[string]float64, error) {
    86  				if cfg.CryptoCompareAPIKey == "" {
    87  					return nil, nil // nothing to do
    88  				}
    89  
    90  				reqURL := fmt.Sprintf(cryptoComparePriceEndpoint, parseTickers(tickers...))
    91  				response := make(map[string]map[string]float64)
    92  				err := getRates(ctx, reqURL, &response)
    93  				if err != nil {
    94  					return nil, fmt.Errorf("unable to fetch fiat rates: %w", err)
    95  				}
    96  
    97  				fiatRates := make(map[string]float64)
    98  				for ticker, rates := range response {
    99  					rate, ok := rates["USD"]
   100  					if ok {
   101  						fiatRates[parseTicker(ticker)] = rate
   102  					}
   103  				}
   104  
   105  				return fiatRates, nil
   106  			},
   107  		},
   108  		{
   109  			name:            kuCoin,
   110  			requestInterval: defaultRefreshInterval,
   111  			disabled:        strings.Contains(disabledSources, strings.ToLower(kuCoin)),
   112  			getRates: func(ctx context.Context, tickers []string, _ dex.Logger) (map[string]float64, error) {
   113  				var response struct {
   114  					Data map[string]string `json:"data"`
   115  				}
   116  
   117  				reqURL := fmt.Sprintf(kuCoinPriceEndpoint, parseTickers(tickers...))
   118  				err := getRates(ctx, reqURL, &response)
   119  				if err != nil {
   120  					return nil, fmt.Errorf("unable to fetch fiat rates: %w", err)
   121  				}
   122  
   123  				fiatRates := make(map[string]float64)
   124  				for ticker, rateStr := range response.Data {
   125  					rate, err := strconv.ParseFloat(rateStr, 64)
   126  					if err != nil {
   127  						log.Error("%s: failed to convert fiat rate for %s to float64: %v", kuCoin, ticker, err)
   128  						continue
   129  					}
   130  					fiatRates[parseTicker(ticker)] = rate
   131  				}
   132  
   133  				return fiatRates, nil
   134  			},
   135  		},
   136  		{
   137  			name:            binance,
   138  			requestInterval: defaultRefreshInterval,
   139  			disabled:        strings.Contains(disabledSources, strings.ToLower(binance)),
   140  			getRates: func(ctx context.Context, tickers []string, _ dex.Logger) (map[string]float64, error) {
   141  				priceEndpoint := binancePriceEndpoint
   142  				if cfg.EnableBinanceUS {
   143  					priceEndpoint = binanceUSPriceEndpoint
   144  				}
   145  
   146  				binanceTickers := parseBinanceTickers(tickers)
   147  				if binanceTickers == "" {
   148  					return nil, nil // nothing to fetch
   149  				}
   150  
   151  				var response []*struct {
   152  					Symbol string `json:"symbol"`
   153  					Price  string `json:"price"`
   154  				}
   155  
   156  				reqURL := fmt.Sprintf(priceEndpoint, url.PathEscape(binanceTickers))
   157  				err := getRates(ctx, reqURL, &response)
   158  				if err != nil {
   159  					return nil, fmt.Errorf("unable to fetch fiat rates: %w", err)
   160  				}
   161  
   162  				fiatRates := make(map[string]float64)
   163  				for _, asset := range response {
   164  					ticker := parseTicker(strings.TrimSuffix(asset.Symbol, "USDT"))
   165  					rate, err := strconv.ParseFloat(asset.Price, 64)
   166  					if err != nil {
   167  						log.Error("%s: failed to convert fiat rate for %s to float64: %v", binance, ticker, err)
   168  						continue
   169  					}
   170  					fiatRates[ticker] = rate
   171  				}
   172  
   173  				return fiatRates, nil
   174  			},
   175  		},
   176  		{
   177  			name:            coinpaprika,
   178  			requestInterval: defaultRefreshInterval,
   179  			disabled:        strings.Contains(disabledSources, strings.ToLower(coinpaprika)),
   180  			getRates: func(ctx context.Context, tickers []string, log dex.Logger) (map[string]float64, error) {
   181  				fiatRates := make(map[string]float64, len(tickers))
   182  				for _, a := range tickers {
   183  					fiatRates[parseTicker(a)] = 0
   184  				}
   185  
   186  				var res []*struct {
   187  					Symbol string `json:"symbol"`
   188  					Quotes struct {
   189  						USD struct {
   190  							Price float64 `json:"price"`
   191  						} `json:"USD"`
   192  					} `json:"quotes"`
   193  				}
   194  
   195  				if err := getRates(ctx, coinpaprikaPriceEndpoint, &res); err != nil {
   196  					return nil, err
   197  				}
   198  
   199  				for _, coinInfo := range res {
   200  					ticker := parseTicker(coinInfo.Symbol)
   201  					_, found := fiatRates[ticker]
   202  					if !found {
   203  						continue
   204  					}
   205  
   206  					price := coinInfo.Quotes.USD.Price
   207  					if price == 0 {
   208  						log.Errorf("zero-price returned from coinpaprika for asset with ticker %s", ticker)
   209  						continue
   210  					}
   211  
   212  					fiatRates[ticker] = price
   213  				}
   214  
   215  				return fiatRates, nil
   216  			},
   217  		},
   218  		{
   219  			name:            messari,
   220  			requestInterval: messariRefreshInterval,
   221  			disabled:        strings.Contains(disabledSources, strings.ToLower(messari)),
   222  			getRates: func(ctx context.Context, tickers []string, log dex.Logger) (map[string]float64, error) {
   223  				fiatRates := make(map[string]float64)
   224  				for _, ticker := range tickers {
   225  					var res struct {
   226  						Data struct {
   227  							MarketData struct {
   228  								Price float64 `json:"price_usd"`
   229  							} `json:"market_data"`
   230  						} `json:"data"`
   231  					}
   232  
   233  					reqURL := fmt.Sprintf(messariPriceEndpoint, parseTickers(ticker))
   234  					if err := getRates(ctx, reqURL, &res); err != nil {
   235  						log.Errorf("Error getting fiat exchange rates from messari: %v", err)
   236  						continue // fetch other tickers
   237  					}
   238  
   239  					fiatRates[parseTicker(ticker)] = res.Data.MarketData.Price
   240  				}
   241  
   242  				return fiatRates, nil
   243  			},
   244  		},
   245  	}
   246  
   247  	for i := range sources {
   248  		sources[i].canReactivate = !sources[i].disabled
   249  	}
   250  
   251  	return sources
   252  }
   253  
   254  func parseTickers(tickerSymbols ...string) string {
   255  	var tickers string
   256  	for _, ticker := range tickerSymbols {
   257  		tickers += parseTicker(ticker) + ","
   258  	}
   259  	return strings.Trim(tickers, ",")
   260  }
   261  
   262  func parseTicker(ticker string) string {
   263  	if strings.EqualFold(ticker, "polygon") {
   264  		return "MATIC"
   265  	} else if strings.EqualFold(ticker, "usdc.eth") || strings.EqualFold(ticker, "usdc.polygon") {
   266  		return "USDC"
   267  	}
   268  	return upperCaser.String(ticker)
   269  }
   270  
   271  func parseBinanceTickers(tickerSymbols []string) string {
   272  	var tickers string
   273  	for _, ticker := range tickerSymbols {
   274  		ticker = parseTicker(ticker)
   275  		if strings.EqualFold(ticker, "zcl") { // not supported on binance as of writing
   276  			continue
   277  		}
   278  		tickers += fmt.Sprintf("%q,", ticker+"USDT")
   279  	}
   280  	return strings.Trim(tickers, ",")
   281  }