package p import ( "context" "encoding/json" "errors" "fmt" "io/ioutil" "log" "os" "strings" "time" "net/http" "github.com/wormhole-foundation/wormhole/sdk/vaa" ) const cgBaseUrl = "https://api.coingecko.com/api/v3/" const cgProBaseUrl = "https://pro-api.coingecko.com/api/v3/" type CoinGeckoCoin struct { Id string `json:"id"` Symbol string `json:"symbol"` Name string `json:"name"` } type CoinGeckoCoins []CoinGeckoCoin type CoinGeckoMarket [2]float64 type CoinGeckoMarketRes struct { Prices []CoinGeckoMarket `json:"prices"` } type CoinGeckoErrorRes struct { Error string `json:"error"` } func fetchCoinGeckoCoins() map[string][]CoinGeckoCoin { defer timeTrack(time.Now(), "fetchCoinGeckoCoins") baseUrl := cgBaseUrl cgApiKey := os.Getenv("COINGECKO_API_KEY") if cgApiKey != "" { baseUrl = cgProBaseUrl } url := fmt.Sprintf("%vcoins/list", baseUrl) req, reqErr := http.NewRequest("GET", url, nil) if reqErr != nil { log.Fatalf("failed coins request, err: %v", reqErr) } if cgApiKey != "" { req.Header.Set("X-Cg-Pro-Api-Key", cgApiKey) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get coins response, err: %v", resErr) } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding coins body, err: %v", bodyErr) } var parsed []CoinGeckoCoin parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchCoinGeckoCoins failed parsing body. err %v\n", parseErr) } var geckoCoins = map[string][]CoinGeckoCoin{} for _, coin := range parsed { symbol := strings.ToLower(coin.Symbol) geckoCoins[symbol] = append(geckoCoins[symbol], coin) } return geckoCoins } func chainIdToCoinGeckoPlatform(chain vaa.ChainID) string { // used when symbol not found in cg's coins/list switch chain { case vaa.ChainIDSolana: return "solana" case vaa.ChainIDEthereum: return "ethereum" case vaa.ChainIDTerra: return "terra" case vaa.ChainIDBSC: return "binance-smart-chain" case vaa.ChainIDPolygon: return "polygon-pos" case vaa.ChainIDAvalanche: return "avalanche" case vaa.ChainIDOasis: return "oasis" case vaa.ChainIDAlgorand: return "algorand" case vaa.ChainIDAurora: return "aurora" case vaa.ChainIDFantom: return "fantom" case vaa.ChainIDKarura: return "karura" case vaa.ChainIDAcala: return "acala" case vaa.ChainIDKlaytn: return "klay-token" case vaa.ChainIDCelo: return "celo" case vaa.ChainIDNear: return "near-protocol" case vaa.ChainIDMoonbeam: return "moonbeam" case vaa.ChainIDTerra2: return "" // TODO case vaa.ChainIDAptos: return "" // TODO: not currently supported case vaa.ChainIDXpla: return "" // TODO: not currently supported case vaa.ChainIDEthereumRopsten: return "ethereum" } return "" } func fetchCoinGeckoCoinFromContract(chainId vaa.ChainID, address string) CoinGeckoCoin { baseUrl := cgBaseUrl cgApiKey := os.Getenv("COINGECKO_API_KEY") if cgApiKey != "" { baseUrl = cgProBaseUrl } platform := chainIdToCoinGeckoPlatform(chainId) url := fmt.Sprintf("%vcoins/%v/contract/%v", baseUrl, platform, address) req, reqErr := http.NewRequest("GET", url, nil) if reqErr != nil { log.Fatalf("failed contract request, err: %v\n", reqErr) } if cgApiKey != "" { req.Header.Set("X-Cg-Pro-Api-Key", cgApiKey) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get contract response, err: %v\n", resErr) } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding contract body, err: %v\n", bodyErr) } var parsed CoinGeckoCoin parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchCoinGeckoCoinFromContract failed parsing body. err %v\n", parseErr) var errRes CoinGeckoErrorRes if err := json.Unmarshal(body, &errRes); err == nil { if errRes.Error == "Could not find coin with the given id" { log.Printf("Could not find CoinGecko coin by contract address, for chain %v, address, %v\n", chainId, address) } else { log.Println("Failed calling CoinGecko, got err", errRes.Error) } } } return parsed } func fetchCoinGeckoCoinId(chainId vaa.ChainID, address, symbol, name string) (coinId, foundSymbol, foundName string) { // try coingecko, return if good // if coingecko does not work, try chain-specific options // initialize strings that will be returned if we find a symbol/name // when looking up this token by contract address newSymbol := "" newName := "" if symbol == "" && chainId == vaa.ChainIDSolana { // try to lookup the symbol in solana token list, from the address if token, ok := solanaTokens[address]; ok { symbol = token.Symbol name = token.Name newSymbol = token.Symbol newName = token.Name } } if _, ok := coinGeckoCoins[strings.ToLower(symbol)]; ok { tokens := coinGeckoCoins[strings.ToLower(symbol)] if len(tokens) == 1 { // only one match found for this symbol return tokens[0].Id, newSymbol, newName } for _, token := range tokens { if token.Name == name { // found token by name match return token.Id, newSymbol, newName } if strings.Contains(strings.ToLower(strings.ReplaceAll(name, " ", "")), strings.ReplaceAll(token.Id, "-", "")) { // found token by id match log.Println("found token by symbol and name match", name) return token.Id, newSymbol, newName } } // more than one symbol with this name, let contract lookup try } coin := fetchCoinGeckoCoinFromContract(chainId, address) if coin.Id != "" { return coin.Id, newSymbol, newName } // could not find a CoinGecko coin return "", newSymbol, newName } func fetchCoinGeckoPrice(coinId string, timestamp time.Time) (float64, error) { if coinId == "" { return 0, errors.New("coinId is empty") } hourAgo := time.Now().Add(-time.Duration(1) * time.Hour) withinLastHour := timestamp.After(hourAgo) start, end := rangeFromTime(timestamp, 12) baseUrl := cgBaseUrl cgApiKey := os.Getenv("COINGECKO_API_KEY") if cgApiKey != "" { baseUrl = cgProBaseUrl } url := fmt.Sprintf("%vcoins/%v/market_chart/range?vs_currency=usd&from=%v&to=%v", baseUrl, coinId, start.Unix(), end.Unix()) req, reqErr := http.NewRequest("GET", url, nil) if reqErr != nil { log.Fatalf("failed coins request, err: %v\n", reqErr) } if cgApiKey != "" { req.Header.Set("X-Cg-Pro-Api-Key", cgApiKey) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get coins response, err: %v\n", resErr) } if res.StatusCode >= 400 { log.Fatal("failed to get CoinGecko prices. Status", res.Status) } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding coins body, err: %v\n", bodyErr) } var parsed CoinGeckoMarketRes parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchCoinGeckoPrice failed parsing body. err %v\n", parseErr) var errRes CoinGeckoErrorRes if err := json.Unmarshal(body, &errRes); err == nil { log.Println("Failed calling CoinGecko, got err", errRes.Error) } } if len(parsed.Prices) >= 1 { var priceIndex int if withinLastHour { // use the last price in the list, latest price priceIndex = len(parsed.Prices) - 1 } else { // use a price from the middle of the list, as that should be // closest to the timestamp. numPrices := len(parsed.Prices) priceIndex = numPrices / 2 } price := parsed.Prices[priceIndex][1] log.Printf("found a price of $%f for %v!\n", price, coinId) return price, nil } log.Println("no price found in coinGecko for", coinId) return 0, fmt.Errorf("no price found for %v", coinId) } type Price struct { USD float64 `json:"usd"` } type CoinGeckoCoinPrices map[string]Price // takes a list of CoinGeckoCoinIds, returns a map of { coinId: price }. func fetchCoinGeckoPrices(coinIds []string) (map[string]float64, error) { baseUrl := cgBaseUrl cgApiKey := os.Getenv("COINGECKO_API_KEY") if cgApiKey != "" { baseUrl = cgProBaseUrl } url := fmt.Sprintf("%vsimple/price?ids=%v&vs_currencies=usd", baseUrl, strings.Join(coinIds, ",")) req, reqErr := http.NewRequest("GET", url, nil) if reqErr != nil { log.Fatalf("failed coins request, err: %v\n", reqErr) } if cgApiKey != "" { req.Header.Set("X-Cg-Pro-Api-Key", cgApiKey) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get coins response, err: %v\n", resErr) } if res.StatusCode >= 400 { log.Fatal("failed to get CoinGecko prices. Status", res.Status) } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding coins body, err: %v\n", bodyErr) } var parsed CoinGeckoCoinPrices parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchCoinGeckoPrice failed parsing body. err %v\n", parseErr) var errRes CoinGeckoErrorRes if err := json.Unmarshal(body, &errRes); err == nil { log.Println("Failed calling CoinGecko, got err", errRes.Error) } } priceMap := map[string]float64{} for coinId, price := range parsed { price := price.USD priceMap[coinId] = price } return priceMap, nil } // takes a list of CoinGeckoCoinIds, returns a map of { coinId: price }. // makes batches of requests to CoinGecko. func fetchTokenPrices(ctx context.Context, coinIds []string) map[string]float64 { allPrices := map[string]float64{} // Split the list into batches, otherwise the request could be too large batch := 100 for i := 0; i < len(coinIds); i += batch { j := i + batch if j > len(coinIds) { j = len(coinIds) } prices, err := fetchCoinGeckoPrices(coinIds[i:j]) if err != nil { log.Fatalf("failed to get price for coinIds. err %v", err) } for coinId, price := range prices { allPrices[coinId] = price } coinGeckoRateLimitSleep() } return allPrices } func coinGeckoRateLimitSleep() { // CoinGecko rate limit is low (5/second), be very cautious about bursty requests time.Sleep(time.Millisecond * 200) } // fetchTokenPriceHistories returns the daily prices for coinIds from start to end func fetchTokenPriceHistories(ctx context.Context, coinIds []string, start time.Time, end time.Time) map[string]map[string]float64 { log.Printf("Fetching price history for %d tokens\n", len(coinIds)) priceHistories := map[string]map[string]float64{} baseUrl := cgBaseUrl cgApiKey := os.Getenv("COINGECKO_API_KEY") if cgApiKey != "" { baseUrl = cgProBaseUrl } startTimestamp := start.Unix() endTimestamp := end.Unix() for _, coinId := range coinIds { defer coinGeckoRateLimitSleep() url := fmt.Sprintf("%vcoins/%v/market_chart/range?vs_currency=usd&from=%v&to=%v", baseUrl, coinId, startTimestamp, endTimestamp) req, reqErr := http.NewRequest("GET", url, nil) if reqErr != nil { log.Fatalf("failed coins request, err: %v\n", reqErr) } if cgApiKey != "" { req.Header.Set("X-Cg-Pro-Api-Key", cgApiKey) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get coins response, err: %v\n", resErr) } defer res.Body.Close() if res.StatusCode >= 400 { errorMsg := fmt.Sprintf("failed to get CoinGecko price history for %s, Status: %s", coinId, res.Status) if res.StatusCode == 404 { log.Println(errorMsg) continue } else { log.Fatalln(errorMsg) } } body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding coins body, err: %v\n", bodyErr) } var parsed CoinGeckoMarketRes parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchTokenPriceHistories failed parsing body. err %v\n", parseErr) var errRes CoinGeckoErrorRes if err := json.Unmarshal(body, &errRes); err == nil { log.Println("Failed calling CoinGecko, got err", errRes.Error) } } else { for _, market := range parsed.Prices { seconds := int64(market[0]) / 1e3 date := time.Unix(seconds, 0).Format("2006-01-02") price := market[1] if _, ok := priceHistories[date]; !ok { priceHistories[date] = map[string]float64{} } priceHistories[date][coinId] = price } } } return priceHistories } const solanaTokenListURL = "https://raw.githubusercontent.com/solana-labs/token-list/main/src/tokens/solana.tokenlist.json" type SolanaToken struct { Address string `json:"address"` Symbol string `json:"symbol"` Name string `json:"name"` Decimals int `json:"decimals"` } type SolanaTokenListRes struct { Tokens []SolanaToken `json:"tokens"` } func fetchSolanaTokenList() map[string]SolanaToken { defer timeTrack(time.Now(), "fetchSolanaTokenList") req, reqErr := http.NewRequest("GET", solanaTokenListURL, nil) if reqErr != nil { log.Fatalf("failed solana token list request, err: %v", reqErr) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Fatalf("failed get solana token list response, err: %v", resErr) } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Fatalf("failed decoding solana token list body, err: %v", bodyErr) } var parsed SolanaTokenListRes parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchSolanaTokenList failed parsing body. err %v\n", parseErr) } var solTokens = map[string]SolanaToken{} for _, token := range parsed.Tokens { if _, ok := solTokens[token.Address]; !ok { solTokens[token.Address] = token } } return solTokens } const solanaBeachPublicBaseURL = "https://prod-api.solana.surf/v1/" const solanaBeachPrivateBaseURL = "https://api.solanabeach.io/v1/" type SolanaBeachAccountOwner struct { Owner SolanaBeachAccountOwnerAddress `json:"owner"` } type SolanaBeachAccountOwnerAddress struct { Address string `json:"address"` } type SolanaBeachAccountResponse struct { Value struct { Extended struct { SolanaBeachAccountOwner } `json:"extended"` } `json:"value"` } func fetchSolanaAccountOwner(account string) string { ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) defer cancel() baseUrl := solanaBeachPublicBaseURL sbApiKey := os.Getenv("SOLANABEACH_API_KEY") if sbApiKey != "" { baseUrl = solanaBeachPrivateBaseURL } url := fmt.Sprintf("%vaccount/%v", baseUrl, account) req, reqErr := http.NewRequestWithContext(ctx, "GET", url, nil) if reqErr != nil { log.Printf("failed solanabeach request, err: %v", reqErr) return "" } if sbApiKey != "" { req.Header.Set("Authorization", fmt.Sprintf("Bearer %v", sbApiKey)) } res, resErr := http.DefaultClient.Do(req) if resErr != nil { log.Printf("failed get solana beach account response, err: %v", resErr) return "" } defer res.Body.Close() body, bodyErr := ioutil.ReadAll(res.Body) if bodyErr != nil { log.Printf("failed decoding solana beach account body, err: %v", bodyErr) return "" } var parsed SolanaBeachAccountResponse parseErr := json.Unmarshal(body, &parsed) if parseErr != nil { log.Printf("fetchSolanaAccountOwner failed parsing body. err %v\n", parseErr) return "" } address := parsed.Value.Extended.Owner.Address if address == "" { log.Println("failed to find owner address for Solana account", account) } return address }