diff --git a/fiat/coingecko.go b/fiat/coingecko.go index 4eb168f3..0e701fd8 100644 --- a/fiat/coingecko.go +++ b/fiat/coingecko.go @@ -21,6 +21,10 @@ const ( DefaultHTTPTimeout = 15 * time.Second DefaultThrottleDelayMs = 100 // 100 ms delay between requests coingeckoFreeHistoryDaysLimit = 365 + coingeckoPlanFree = "free" + coingeckoPlanPro = "pro" + coingeckoFreeAPIURL = "https://api.coingecko.com/api/v3" + coingeckoProAPIURL = "https://pro-api.coingecko.com/api/v3" ) // Coingecko is a structure that implements RatesDownloaderInterface @@ -73,20 +77,15 @@ func NewCoinGeckoDownloader(db *db.RocksDB, network string, url string, coin str if apiKey == "" { apiKey = os.Getenv("COINGECKO_API_KEY") } + hasAPIKey := apiKey != "" + plan = resolveCoinGeckoPlan(plan, url, hasAPIKey) // use default address if not overridden, with respect to existence of apiKey if url == "" { - const ( - proURL = "https://pro-api.coingecko.com/api/v3" - freeURL = "https://api.coingecko.com/api/v3" - ) - - plan = strings.ToLower(strings.TrimSpace(plan)) - - if apiKey != "" && plan != "free" { - url = proURL + if plan == coingeckoPlanPro { + url = coingeckoProAPIURL } else { - url = freeURL + url = coingeckoFreeAPIURL } } glog.Info("Coingecko downloader url ", url) @@ -110,6 +109,50 @@ func NewCoinGeckoDownloader(db *db.RocksDB, network string, url string, coin str } } +func normalizeCoinGeckoURL(apiURL string) string { + return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(apiURL)), "/") +} + +func inferCoinGeckoPlanFromURL(apiURL string) (string, bool) { + switch normalizeCoinGeckoURL(apiURL) { + case coingeckoFreeAPIURL: + return coingeckoPlanFree, true + case coingeckoProAPIURL: + return coingeckoPlanPro, true + default: + return "", false + } +} + +func resolveCoinGeckoPlan(plan string, apiURL string, hasAPIKey bool) string { + normalizedPlan := strings.ToLower(strings.TrimSpace(plan)) + switch normalizedPlan { + case coingeckoPlanFree: + return coingeckoPlanFree + case coingeckoPlanPro: + return coingeckoPlanPro + case "": + // Continue with inference for backward compatibility. + default: + glog.Warningf("Coingecko unknown plan %q, defaulting by API key presence", plan) + if hasAPIKey { + return coingeckoPlanPro + } + return coingeckoPlanFree + } + + if inferredPlan, ok := inferCoinGeckoPlanFromURL(apiURL); ok { + return inferredPlan + } + + // Backward compatibility for existing deployments: + // API key implied Pro before plan was introduced. + if hasAPIKey { + return coingeckoPlanPro + } + return coingeckoPlanFree +} + // getAllowedVsCurrenciesMap returns a map of allowed vs currencies func getAllowedVsCurrenciesMap(currenciesString string) map[string]struct{} { allowedVsCurrenciesMap := make(map[string]struct{}) @@ -139,7 +182,7 @@ func doReq(req *http.Request, client *http.Client) ([]byte, error) { } // makeReq HTTP request helper - will retry the call after 1 minute on error -func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte, error) { +func (cg *Coingecko) makeReq(url string, endpoint string) ([]byte, error) { for { // glog.Infof("Coingecko makeReq %v", url) req, err := http.NewRequest("GET", url, nil) @@ -148,7 +191,7 @@ func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte, } req.Header.Set("Content-Type", "application/json") if cg.apiKey != "" { - if plan == "pro" { + if cg.plan == coingeckoPlanPro { req.Header.Set("x-cg-pro-api-key", cg.apiKey) } else { req.Header.Set("x-cg-demo-api-key", cg.apiKey) @@ -180,7 +223,7 @@ func (cg *Coingecko) makeReq(url string, endpoint string, plan string) ([]byte, // SimpleSupportedVSCurrencies /simple/supported_vs_currencies func (cg *Coingecko) simpleSupportedVSCurrencies() (simpleSupportedVSCurrencies, error) { url := cg.url + "/simple/supported_vs_currencies" - resp, err := cg.makeReq(url, "supported_vs_currencies", cg.plan) + resp, err := cg.makeReq(url, "supported_vs_currencies") if err != nil { return nil, err } @@ -211,7 +254,7 @@ func (cg *Coingecko) simplePrice(ids []string, vsCurrencies []string) (*map[stri params.Add("vs_currencies", vsCurrenciesParam) url := fmt.Sprintf("%s/simple/price?%s", cg.url, params.Encode()) - resp, err := cg.makeReq(url, "simple/price", cg.plan) + resp, err := cg.makeReq(url, "simple/price") if err != nil { return nil, err } @@ -234,7 +277,7 @@ func (cg *Coingecko) coinsList() (coinList, error) { } params.Add("include_platform", platform) url := fmt.Sprintf("%s/coins/list?%s", cg.url, params.Encode()) - resp, err := cg.makeReq(url, "coins/list", cg.plan) + resp, err := cg.makeReq(url, "coins/list") if err != nil { return nil, err } @@ -261,7 +304,7 @@ func (cg *Coingecko) coinMarketChart(id string, vs_currency string, days string, params.Add("days", days) url := fmt.Sprintf("%s/coins/%s/market_chart?%s", cg.url, id, params.Encode()) - resp, err := cg.makeReq(url, "market_chart", cg.plan) + resp, err := cg.makeReq(url, "market_chart") if err != nil { return nil, err } @@ -397,21 +440,10 @@ func (cg *Coingecko) FiveMinutesTickers() (*[]common.CurrencyRatesTicker, error) } func (cg *Coingecko) historicalRangeDaysLimit() int { - plan := strings.ToLower(strings.TrimSpace(cg.plan)) - if plan == "pro" { + if cg.plan == coingeckoPlanPro { return 0 } - if plan == "free" { - return coingeckoFreeHistoryDaysLimit - } - // Default public endpoint has historical range limits. - if strings.Contains(cg.url, "pro-api.coingecko.com") { - return 0 - } - if strings.Contains(cg.url, "api.coingecko.com") { - return coingeckoFreeHistoryDaysLimit - } - return 0 + return coingeckoFreeHistoryDaysLimit } func (cg *Coingecko) resolveHistoricalDays(lastTicker *common.CurrencyRatesTicker) (string, bool) { diff --git a/fiat/coingecko_test.go b/fiat/coingecko_test.go index c9f4746a..c56f764a 100644 --- a/fiat/coingecko_test.go +++ b/fiat/coingecko_test.go @@ -10,9 +10,92 @@ import ( "github.com/trezor/blockbook/common" ) +func TestResolveCoinGeckoPlan(t *testing.T) { + tests := []struct { + name string + plan string + url string + hasAPIKey bool + want string + }{ + { + name: "explicit free overrides pro url and api key", + plan: "free", + url: coingeckoProAPIURL, + hasAPIKey: true, + want: coingeckoPlanFree, + }, + { + name: "explicit pro", + plan: "pro", + url: "", + hasAPIKey: false, + want: coingeckoPlanPro, + }, + { + name: "infer pro from pro url", + plan: "", + url: coingeckoProAPIURL, + hasAPIKey: false, + want: coingeckoPlanPro, + }, + { + name: "infer pro from pro url with trailing slash and uppercase", + plan: "", + url: "HTTPS://PRO-API.COINGECKO.COM/API/V3/", + hasAPIKey: false, + want: coingeckoPlanPro, + }, + { + name: "infer free from public url", + plan: "", + url: coingeckoFreeAPIURL, + hasAPIKey: true, + want: coingeckoPlanFree, + }, + { + name: "empty plan with api key stays backward compatible and defaults to pro", + plan: "", + url: "", + hasAPIKey: true, + want: coingeckoPlanPro, + }, + { + name: "empty plan without api key defaults to free", + plan: "", + url: "", + hasAPIKey: false, + want: coingeckoPlanFree, + }, + { + name: "unknown plan falls back to api key default", + plan: "enterprise", + url: "", + hasAPIKey: true, + want: coingeckoPlanPro, + }, + { + name: "unknown plan skips url inference and falls back to api key default", + plan: "enterprise", + url: coingeckoFreeAPIURL, + hasAPIKey: true, + want: coingeckoPlanPro, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveCoinGeckoPlan(tt.plan, tt.url, tt.hasAPIKey) + if got != tt.want { + t.Fatalf("unexpected plan: got %q, want %q", got, tt.want) + } + }) + } +} + func TestResolveHistoricalDays_FreeAPIWithoutLastTickerUses365(t *testing.T) { cg := &Coingecko{ - url: "https://api.coingecko.com/api/v3", + plan: coingeckoPlanFree, } days, shouldRequest := cg.resolveHistoricalDays(nil) @@ -26,8 +109,7 @@ func TestResolveHistoricalDays_FreeAPIWithoutLastTickerUses365(t *testing.T) { func TestResolveHistoricalDays_ProAPIWithoutLastTickerUsesMax(t *testing.T) { cg := &Coingecko{ - url: "https://pro-api.coingecko.com/api/v3", - plan: "pro", + plan: coingeckoPlanPro, } days, shouldRequest := cg.resolveHistoricalDays(nil) @@ -41,7 +123,7 @@ func TestResolveHistoricalDays_ProAPIWithoutLastTickerUsesMax(t *testing.T) { func TestResolveHistoricalDays_FreeAPICapsLongLookbackTo365(t *testing.T) { cg := &Coingecko{ - url: "https://api.coingecko.com/api/v3", + plan: coingeckoPlanFree, } days, shouldRequest := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ @@ -57,7 +139,7 @@ func TestResolveHistoricalDays_FreeAPICapsLongLookbackTo365(t *testing.T) { func TestResolveHistoricalDays_SkipsWhenSameDayTickerExists(t *testing.T) { cg := &Coingecko{ - url: "https://api.coingecko.com/api/v3", + plan: coingeckoPlanFree, } days, shouldRequest := cg.resolveHistoricalDays(&common.CurrencyRatesTicker{ @@ -71,6 +153,18 @@ func TestResolveHistoricalDays_SkipsWhenSameDayTickerExists(t *testing.T) { } } +func TestHistoricalRangeDaysLimit_DependsOnPlan(t *testing.T) { + free := (&Coingecko{plan: coingeckoPlanFree}).historicalRangeDaysLimit() + if free != coingeckoFreeHistoryDaysLimit { + t.Fatalf("unexpected free limit: got %d, want %d", free, coingeckoFreeHistoryDaysLimit) + } + + pro := (&Coingecko{plan: coingeckoPlanPro}).historicalRangeDaysLimit() + if pro != 0 { + t.Fatalf("unexpected pro limit: got %d, want %d", pro, 0) + } +} + func TestIsHistoricalRangeLimitError(t *testing.T) { rangeErr := fmt.Errorf(`{"error":{"status":{"error_code":10012,"error_message":"Your request exceeds the allowed time range. Public API users are limited to querying historical data within the past 365 days."}}}`) if !isHistoricalRangeLimitError(rangeErr) { diff --git a/fiat/fiat_rates_test.go b/fiat/fiat_rates_test.go index 2dfdff5e..d5596499 100644 --- a/fiat/fiat_rates_test.go +++ b/fiat/fiat_rates_test.go @@ -128,7 +128,7 @@ func TestFiatRates(t *testing.T) { config := common.Config{ CoinName: "fakecoin", FiatRates: "coingecko", - FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60}`, + FiatRatesParams: `{"url": "` + mockServer.URL + `", "coin": "ethereum","platformIdentifier": "ethereum","platformVsCurrency": "eth","periodSeconds": 60,"plan":"pro"}`, } d, _, tmp := setupRocksDB(t, &testBitcoinParser{