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 }