diff --git a/config/supported_assets.go b/config/supported_assets.go index 3b04e74e..f9891bf3 100644 --- a/config/supported_assets.go +++ b/config/supported_assets.go @@ -24,6 +24,7 @@ var ( provider.ProviderCrypto: false, provider.ProviderPolygon: true, provider.ProviderMock: false, + provider.ProviderFin: false, } // SupportedQuotes defines a lookup table for which assets we support diff --git a/oracle/oracle.go b/oracle/oracle.go index 39377a53..e51abaab 100644 --- a/oracle/oracle.go +++ b/oracle/oracle.go @@ -484,6 +484,9 @@ func NewProvider( case provider.ProviderPolygon: return provider.NewPolygonProvider(ctx, logger, endpoint, providerPairs...) + case provider.ProviderFin: + return provider.NewFinProvider(endpoint), nil + case provider.ProviderMock: return provider.NewMockProvider(), nil } diff --git a/oracle/provider/fin.go b/oracle/provider/fin.go new file mode 100644 index 00000000..2d512849 --- /dev/null +++ b/oracle/provider/fin.go @@ -0,0 +1,313 @@ +package provider + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/ojo-network/price-feeder/oracle/types" +) + +const ( + finRestURL = "https://api.kujira.app" + finPairsEndpoint = "/api/coingecko/pairs" + finTickersEndpoint = "/api/coingecko/tickers" + finCandlesEndpoint = "/api/trades/candles" + finCandleBinSizeMinutes = 5 + finCandleWindowSizeHours = 240 +) + +var _ Provider = (*FinProvider)(nil) + +type ( + // FinProvider implements an Oracle provider for use with the + // json-based FIN API. + // + // It polls and caches all data once every 30 seconds. + FinProvider struct { + baseURL string + client *http.Client + } + + FinTickers struct { + Tickers []FinTicker `json:"tickers"` + } + FinTicker struct { + Base string `json:"base_currency"` + Target string `json:"target_currency"` + Symbol string `json:"ticker_id"` + Price string `json:"last_price"` + Volume string `json:"base_volume"` + } + + FinCandles struct { + Candles []FinCandle `json:"candles"` + } + FinCandle struct { + Bin string `json:"bin"` + Close string `json:"close"` + Volume string `json:"volume"` + } + + FinPairs struct { + Pairs []FinPair `json:"pairs"` + } + FinPair struct { + Base string `json:"base"` + Target string `json:"target"` + Symbol string `json:"ticker_id"` + Address string `json:"pool_id"` + } +) + +// NewFinProvider returns a new instance of the Fin Provider. +func NewFinProvider(endpoint Endpoint) *FinProvider { + if endpoint.Name == ProviderFin { + return &FinProvider{ + baseURL: endpoint.Rest, + client: newDefaultHTTPClient(), + } + } + return &FinProvider{ + baseURL: finRestURL, + client: newDefaultHTTPClient(), + } +} + +// GetTickerPrices queries the FIN json API and returns with a +// map of string => types.TickerPrice. +func (p FinProvider) GetTickerPrices(pairs ...types.CurrencyPair) ( + map[string]types.TickerPrice, error, +) { + path := fmt.Sprintf("%s%s", p.baseURL, finTickersEndpoint) + tickerResponse, err := p.client.Get(path) + if err != nil { + return nil, fmt.Errorf("FIN tickers request failed: %w", err) + } + defer tickerResponse.Body.Close() + + tickerContent, err := io.ReadAll(tickerResponse.Body) + if err != nil { + return nil, + fmt.Errorf("FIN tickers response read failed: %w", err) + } + var tickers FinTickers + err = json.Unmarshal(tickerContent, &tickers) + if err != nil { + return nil, + fmt.Errorf("FIN tickers response unmarshal failed: %w", err) + } + return tickers.toTickerPrices(pairs) +} + +// toTickerPrices takes a FinTickers object and returns a +// map of string => types.TickerPrice. +func (ft FinTickers) toTickerPrices(pairs []types.CurrencyPair) ( + map[string]types.TickerPrice, error, +) { + tickerSymbolPairs := make(map[string]types.CurrencyPair, len(pairs)) + for _, pair := range pairs { + tickerSymbolPairs[pair.Base+"_"+pair.Quote] = pair + } + tickerPrices := make(map[string]types.TickerPrice, len(pairs)) + for _, ticker := range ft.Tickers { + pair, ok := tickerSymbolPairs[strings.ToUpper(ticker.Symbol)] + if !ok { + // skip tokens that are not requested + continue + } + _, ok = tickerPrices[pair.String()] + if ok { + return nil, + fmt.Errorf("FIN tickers response contained duplicate: %s", ticker.Symbol) + } + + price, err := strToDec(ticker.Price) + if err != nil { + return nil, err + } + volume, err := strToDec(ticker.Volume) + if err != nil { + return nil, err + } + tickerPrices[pair.String()] = types.TickerPrice{ + Price: price, + Volume: volume, + } + } + + for _, pair := range pairs { + _, ok := tickerPrices[pair.String()] + if !ok { + return nil, + fmt.Errorf("FIN ticker price missing for pair: %s", pair.String()) + } + } + return tickerPrices, nil +} + +// GetCandlePrices queries the FIN json API for each pair, +// gets each set of candles, and returns with a map +// of string => []types.CandlePrice. +func (p FinProvider) GetCandlePrices(pairs ...types.CurrencyPair) ( + map[string][]types.CandlePrice, error, +) { + pairAddresses, err := p.getFinPairAddresses() + if err != nil { + return nil, + fmt.Errorf("FIN pair addresses lookup failed: %w", err) + } + + candlePricesPairs := make(map[string][]types.CandlePrice) + for _, pair := range pairs { + address, ok := pairAddresses[pair.String()] + if !ok { + return nil, + fmt.Errorf("FIN contract address lookup failed for pair: %s", pair.String()) + } + candlePricesPairs[pair.String()] = []types.CandlePrice{} + + windowEndTime := time.Now() + windowStartTime := windowEndTime.Add(-finCandleWindowSizeHours * time.Hour) + path := fmt.Sprintf("%s%s?contract=%s&precision=%d&from=%s&to=%s", + p.baseURL, + finCandlesEndpoint, + address, + finCandleBinSizeMinutes, + windowStartTime.Format(time.RFC3339), + windowEndTime.Format(time.RFC3339), + ) + candlesResponse, err := p.client.Get(path) + if err != nil { + return nil, fmt.Errorf("FIN candles request failed: %w", err) + } + defer candlesResponse.Body.Close() + + candlesContent, err := io.ReadAll(candlesResponse.Body) + if err != nil { + return nil, fmt.Errorf("FIN candles response read failed: %w", err) + } + var candles FinCandles + err = json.Unmarshal(candlesContent, &candles) + if err != nil { + return nil, fmt.Errorf("FIN candles response unmarshal failed: %w", err) + } + + cp, err := candles.ToCandlePrice() + if err != nil { + return nil, err + } + candlePricesPairs[pair.String()] = cp + } + return candlePricesPairs, nil +} + +// ToCandlePrice converts a FinCandles object to a []types.CandlePrice object. +func (fc FinCandles) ToCandlePrice() ([]types.CandlePrice, error) { + candlePrices := []types.CandlePrice{} + for _, candle := range fc.Candles { + timeStamp, err := binToTimeStamp(candle.Bin) + if err != nil { + return nil, fmt.Errorf("FIN candle timestamp failed to parse: %s", candle.Bin) + } + + close, err := strToDec(candle.Close) + if err != nil { + return nil, err + } + volume, err := strToDec(candle.Volume) + if err != nil { + return nil, err + } + candlePrices = append(candlePrices, types.CandlePrice{ + Price: close, + Volume: volume, + TimeStamp: timeStamp, + }) + } + + return candlePrices, nil +} + +// GetAvailablePairs queries fin's pairs and returns a map of +// pair => empty struct. +func (p FinProvider) GetAvailablePairs() (map[string]struct{}, error) { + finPairs, err := p.getFinPairs() + if err != nil { + return nil, err + } + availablePairs := make(map[string]struct{}, len(finPairs.Pairs)) + for _, pair := range finPairs.Pairs { + pair := types.CurrencyPair{ + Base: strings.ToUpper(pair.Base), + Quote: strings.ToUpper(pair.Target), + } + availablePairs[pair.String()] = struct{}{} + } + return availablePairs, nil +} + +// getFinPairs queries the fin json API for available pairs, +// parses it, and returns it. +func (p FinProvider) getFinPairs() (FinPairs, error) { + path := fmt.Sprintf("%s%s", p.baseURL, finPairsEndpoint) + pairsResponse, err := p.client.Get(path) + if err != nil { + return FinPairs{}, err + } + defer pairsResponse.Body.Close() + var pairs FinPairs + err = json.NewDecoder(pairsResponse.Body).Decode(&pairs) + if err != nil { + return FinPairs{}, err + } + return pairs, nil +} + +// getFinPairAddresses queries the fin API for token pairs, +// and returns a map of [base+quote] => pool id address. +func (p FinProvider) getFinPairAddresses() (map[string]string, error) { + finPairs, err := p.getFinPairs() + if err != nil { + return nil, err + } + pairAddresses := make(map[string]string, len(finPairs.Pairs)) + for _, pair := range finPairs.Pairs { + pairAddresses[strings.ToUpper(pair.Base+pair.Target)] = pair.Address + } + return pairAddresses, nil +} + +// SubscribeCurrencyPairs performs a no-op since fin does not use websockets +func (p FinProvider) SubscribeCurrencyPairs(_ ...types.CurrencyPair) {} + +// binToTimeStamp takes a bin time expressed in a string +// and converts it into a unix timestamp. +func binToTimeStamp(bin string) (int64, error) { + timeParsed, err := time.Parse(time.RFC3339, bin) + if err != nil { + return -1, err + } + return timeParsed.Unix(), nil +} + +// strToDec converts fin provider's decimals as a string to sdk.Dec. +// It's necessary because the precision of the decimal returned +// by the API is greater than what's supported by sdk.Dec (18). +func strToDec(str string) (sdk.Dec, error) { + if strings.Contains(str, ".") { + split := strings.Split(str, ".") + if len(split[1]) > 18 { + str = split[0] + "." + split[1][0:18] + } + } + return sdk.NewDecFromStr(str) +} + +// StartConnections performs a no-op since fin is not a websocket +// provider. +func (p FinProvider) StartConnections() {} diff --git a/oracle/provider/fin_test.go b/oracle/provider/fin_test.go new file mode 100644 index 00000000..563fa7eb --- /dev/null +++ b/oracle/provider/fin_test.go @@ -0,0 +1,224 @@ +package provider + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ojo-network/price-feeder/oracle/types" + + sdk "github.com/cosmos/cosmos-sdk/types" + "github.com/stretchr/testify/require" +) + +func TestFinProvider_GetTickerPrices(t *testing.T) { + p := NewFinProvider(Endpoint{}) + + t.Run("valid_request_single_ticker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "/api/coingecko/tickers", req.URL.String()) + resp := `{ + "tickers": [{ + "ask":"0.9640000000", + "base_currency":"KUJI", + "base_volume":"544225.3735890000", + "bid":"0.9450000000", + "high":"0.9830000001", + "last_price":"0.9640001379", + "low":"0.7712884676", + "pool_id":"kujira14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sl4e867", + "target_currency":"axlUSDC", + "target_volume":"480430.1470840000", + "ticker_id":"KUJI_axlUSDC" + }] + }` + rw.Write([]byte(resp)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetTickerPrices(types.CurrencyPair{Base: "KUJI", Quote: "AXLUSDC"}) + require.NoError(t, err) + require.Len(t, prices, 1) + require.Equal(t, sdk.MustNewDecFromStr("0.9640001379"), prices["KUJIAXLUSDC"].Price) + require.Equal(t, sdk.MustNewDecFromStr("544225.3735890000"), prices["KUJIAXLUSDC"].Volume) + }) + + t.Run("valid_request_multi_ticker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "/api/coingecko/tickers", req.URL.String()) + resp := `{ + "tickers": [{ + "ask":"0.9640000000", + "base_currency":"KUJI", + "base_volume":"544225.3735890000", + "bid":"0.9450000000", + "high":"0.9830000001", + "last_price":"0.9640001379", + "low":"0.7712884676", + "pool_id":"kujira14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sl4e867", + "target_currency":"axlUSDC", + "target_volume":"480430.1470840000", + "ticker_id":"KUJI_axlUSDC" + }, { + "ask":"1.8750000000", + "base_currency":"EVMOS", + "base_volume":"122.0077927374", + "bid":"1.5110000000", + "high":"1.8650000000", + "last_price":"1.8650000000", + "low":"1.5087335000", + "pool_id":"kujira182nff4ttmvshn6yjlqj5czapfcav9434l2qzz8aahf5pxnyd33tsz30aw6", + "target_currency":"axlUSDC", + "target_volume":"211.3908830000", + "ticker_id":"EVMOS_axlUSDC" + }] + }` + rw.Write([]byte(resp)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetTickerPrices( + types.CurrencyPair{Base: "KUJI", Quote: "AXLUSDC"}, + types.CurrencyPair{Base: "EVMOS", Quote: "AXLUSDC"}, + ) + require.NoError(t, err) + require.Len(t, prices, 2) + require.Equal(t, sdk.MustNewDecFromStr("0.9640001379"), prices["KUJIAXLUSDC"].Price) + require.Equal(t, sdk.MustNewDecFromStr("544225.3735890000"), prices["KUJIAXLUSDC"].Volume) + require.Equal(t, sdk.MustNewDecFromStr("1.8650000000"), prices["EVMOSAXLUSDC"].Price) + require.Equal(t, sdk.MustNewDecFromStr("122.0077927374"), prices["EVMOSAXLUSDC"].Volume) + }) + + t.Run("invalid_request_bad_response", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "/api/coingecko/tickers", req.URL.String()) + rw.Write([]byte(`FOO`)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetTickerPrices(types.CurrencyPair{Base: "ATOM", Quote: "USDT"}) + require.Error(t, err) + require.Nil(t, prices) + }) + + t.Run("invalid_request_invalid_ticker", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "/api/coingecko/tickers", req.URL.String()) + resp := `{ + "tickers": [{ + "ask":"0.9640000000", + "base_currency":"KUJI", + "base_volume":"544225.3735890000", + "bid":"0.9450000000", + "high":"0.9830000001", + "last_price":"0.9640001379", + "low":"0.7712884676", + "pool_id":"kujira14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sl4e867", + "target_currency":"axlUSDC", + "target_volume":"480430.1470840000", + "ticker_id":"KUJI_axlUSDC" + }] + }` + rw.Write([]byte(resp)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetTickerPrices(types.CurrencyPair{Base: "FOO", Quote: "BAR"}) + require.Error(t, err) + require.Nil(t, prices) + }) + + t.Run("check_redirect", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + http.Redirect(rw, r, p.baseURL, http.StatusTemporaryRedirect) + })) + defer server.Close() + server.Client().CheckRedirect = preventRedirect + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetTickerPrices(types.CurrencyPair{Base: "ATOM", Quote: "USDT"}) + require.Error(t, err) + require.Nil(t, prices) + }) +} + +func TestFinProvider_GetCandlePrices(t *testing.T) { + p := NewFinProvider(Endpoint{}) + + t.Run("valid_request_single_candle", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + requestUrl := req.URL.String() + resp := "" + if requestUrl == "/api/coingecko/pairs" { + resp = `{ + "pairs": [ + {"base":"KUJI","pool_id":"kujiraTESTADDRESS","target":"axlUSDC","ticker_id":"KUJI_axlUSDC"} + ] + }` + } else { + require.Equal(t, "/api/trades/candles?contract=kujiraTESTADDRESS", strings.Split(requestUrl, "&")[0]) + resp = `{ + "candles": [ + {"bin":"2022-08-07T14:05:00.000000Z","close":"0.65600004530055243509","high":"0.65600004530055243509","low":"0.65600004530055243509","open":"0.65600004530055243509","volume":"7646000"}, + {"bin":"2022-08-07T14:10:00.000000Z","close":"0.65600004530055243509","high":"0.65600004530055243509","low":"0.65600004530055243509","open":"0.65600004530055243509","volume":"0"}, + {"bin":"2022-08-07T14:15:00.000000Z","close":"0.65928507215810458196","high":"0.65928507215810458196","low":"0.65928507215810458196","open":"0.65928507215810458196","volume":"622000000"} + ] + }` + } + rw.Write([]byte(resp)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + prices, err := p.GetCandlePrices(types.CurrencyPair{Base: "KUJI", Quote: "AXLUSDC"}) + require.NoError(t, err) + require.Len(t, prices, 1) + require.Len(t, prices["KUJIAXLUSDC"], 3) + require.Equal(t, sdk.MustNewDecFromStr("0.656000045300552435"), prices["KUJIAXLUSDC"][0].Price) + require.Equal(t, sdk.MustNewDecFromStr("0.659285072158104581"), prices["KUJIAXLUSDC"][2].Price) + require.Equal(t, sdk.MustNewDecFromStr("7646000"), prices["KUJIAXLUSDC"][0].Volume) + require.Equal(t, sdk.MustNewDecFromStr("0"), prices["KUJIAXLUSDC"][1].Volume) + require.Equal(t, int64(1659881100), prices["KUJIAXLUSDC"][0].TimeStamp) + require.Equal(t, int64(1659881700), prices["KUJIAXLUSDC"][2].TimeStamp) + }) +} + +func TestFinProvider_GetAvailablePairs(t *testing.T) { + p := NewFinProvider(Endpoint{}) + p.GetAvailablePairs() + + t.Run("valid_available_pair", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + require.Equal(t, "/api/coingecko/pairs", req.URL.String()) + resp := `{ + "pairs":[{ + "base":"KUJI", + "pool_id":"kujira14hj2tavq8fpesdwxxcu44rty3hh90vhujrvcmstl4zr3txmfvw9sl4e867", + "target":"axlUSDC", + "ticker_id":"KUJI_axlUSDC" + }, + { + "base":"wETH", + "pool_id":"kujira1suhgf5svhu4usrurvxzlgn54ksxmn8gljarjtxqnapv8kjnp4nrsqq4jjh", + "target":"axlUSDC", + "ticker_id":"wETH_axlUSDC" + }] + }` + rw.Write([]byte(resp)) + })) + defer server.Close() + p.client = server.Client() + p.baseURL = server.URL + availablePairs, err := p.GetAvailablePairs() + require.Nil(t, err) + _, exist := availablePairs["KUJIAXLUSDC"] + require.True(t, exist) + _, exist = availablePairs["WETHAXLUSDC"] + require.True(t, exist) + }) +} diff --git a/oracle/provider/provider.go b/oracle/provider/provider.go index e94bd4b1..8e9bbdf8 100644 --- a/oracle/provider/provider.go +++ b/oracle/provider/provider.go @@ -25,6 +25,7 @@ const ( ProviderMexc Name = "mexc" ProviderCrypto Name = "crypto" ProviderPolygon Name = "polygon" + ProviderFin Name = "fin" ProviderMock Name = "mock" )