wormhole/event_database/cloud_functions/external-data.go

544 lines
15 KiB
Go

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
}
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
}