diff --git a/api/worker.go b/api/worker.go index cc9d1af6..cad14df1 100644 --- a/api/worker.go +++ b/api/worker.go @@ -1342,7 +1342,6 @@ func (w *Worker) setFiatRateToBalanceHistories(histories BalanceHistories, curre for i := range histories { bh := &histories[i] t := time.Unix(int64(bh.Time), 0) - // TODO ticker, err := w.db.FiatRatesFindTicker(&t, "", "") if err != nil { glog.Errorf("Error finding ticker by date %v. Error: %v", t, err) @@ -1594,8 +1593,20 @@ func removeEmpty(stringSlice []string) []string { } // getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result -func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRatesTicker) (*FiatTicker, error) { - currencies = removeEmpty(currencies) +func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRatesTicker, token string) (*FiatTicker, error) { + if token != "" { + if len(currencies) != 1 { + return nil, NewAPIError("Rates for token only for a single currency", true) + } + rate := ticker.TokenRateInCurrency(token, currencies[0]) + if rate <= 0 { + rate = -1 + } + return &FiatTicker{ + Timestamp: ticker.Timestamp.UTC().Unix(), + Rates: map[string]float32{currencies[0]: rate}, + }, nil + } if len(currencies) == 0 { // Return all available ticker rates return &FiatTicker{ @@ -1620,7 +1631,7 @@ func (w *Worker) getFiatRatesResult(currencies []string, ticker *db.CurrencyRate } // GetFiatRatesForBlockID returns fiat rates for block height or block hash -func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string) (*FiatTicker, error) { +func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string, token string) (*FiatTicker, error) { var ticker *db.CurrencyRatesTicker bi, err := w.getBlockInfoFromBlockID(blockID) if err != nil { @@ -1631,14 +1642,18 @@ func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string) (*F } dbi := &db.BlockInfo{Time: bi.Time} // get Unix timestamp from block tm := time.Unix(dbi.Time, 0) // convert it to Time object - // TODO - ticker, err = w.db.FiatRatesFindTicker(&tm, "", "") + vsCurrency := "" + currencies = removeEmpty(currencies) + if len(currencies) == 1 { + vsCurrency = currencies[0] + } + ticker, err = w.db.FiatRatesFindTicker(&tm, vsCurrency, token) if err != nil { return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) } else if ticker == nil { return nil, NewAPIError(fmt.Sprintf("No tickers available for %s", tm), true) } - result, err := w.getFiatRatesResult(currencies, ticker) + result, err := w.getFiatRatesResult(currencies, ticker, token) if err != nil { return nil, err } @@ -1646,22 +1661,29 @@ func (w *Worker) GetFiatRatesForBlockID(blockID string, currencies []string) (*F } // GetCurrentFiatRates returns last available fiat rates -func (w *Worker) GetCurrentFiatRates(currencies []string) (*FiatTicker, error) { - // TODO - ticker, err := w.db.FiatRatesFindLastTicker("", "") - if err != nil { - return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) - } else if ticker == nil { - return nil, NewAPIError(fmt.Sprintf("No tickers found!"), true) +func (w *Worker) GetCurrentFiatRates(currencies []string, token string) (*FiatTicker, error) { + vsCurrency := "" + currencies = removeEmpty(currencies) + if len(currencies) == 1 { + vsCurrency = currencies[0] } - result, err := w.getFiatRatesResult(currencies, ticker) + ticker, err := w.db.FiatRatesGetCurrentTicker(vsCurrency, token) + if ticker == nil || err != nil { + ticker, err = w.db.FiatRatesFindLastTicker(vsCurrency, token) + if err != nil { + return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) + } else if ticker == nil { + return nil, NewAPIError(fmt.Sprintf("No tickers found!"), true) + } + } + result, err := w.getFiatRatesResult(currencies, ticker, token) if err != nil { return nil, err } return result, nil } -// makeErrorRates returns a map of currrencies, with each value equal to -1 +// makeErrorRates returns a map of currencies, with each value equal to -1 // used when there was an error finding ticker func makeErrorRates(currencies []string) map[string]float32 { rates := make(map[string]float32) @@ -1672,18 +1694,21 @@ func makeErrorRates(currencies []string) map[string]float32 { } // GetFiatRatesForTimestamps returns fiat rates for each of the provided dates -func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string) (*FiatTickers, error) { +func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (*FiatTickers, error) { if len(timestamps) == 0 { return nil, NewAPIError("No timestamps provided", true) } + vsCurrency := "" currencies = removeEmpty(currencies) + if len(currencies) == 1 { + vsCurrency = currencies[0] + } ret := &FiatTickers{} for _, timestamp := range timestamps { date := time.Unix(timestamp, 0) date = date.UTC() - // TODO - ticker, err := w.db.FiatRatesFindTicker(&date, "", "") + ticker, err := w.db.FiatRatesFindTicker(&date, vsCurrency, token) if err != nil { glog.Errorf("Error finding ticker for date %v. Error: %v", date, err) ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) @@ -1692,8 +1717,13 @@ func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []stri ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) continue } - result, err := w.getFiatRatesResult(currencies, ticker) + result, err := w.getFiatRatesResult(currencies, ticker, token) if err != nil { + if apiErr, ok := err.(*APIError); ok { + if apiErr.Public { + return nil, err + } + } ret.Tickers = append(ret.Tickers, FiatTicker{Timestamp: date.Unix(), Rates: makeErrorRates(currencies)}) continue } @@ -1703,12 +1733,11 @@ func (w *Worker) GetFiatRatesForTimestamps(timestamps []int64, currencies []stri } // GetAvailableVsCurrencies returns the list of available versus currencies for exchange rates -func (w *Worker) GetAvailableVsCurrencies(timestamp int64) (*AvailableVsCurrencies, error) { +func (w *Worker) GetAvailableVsCurrencies(timestamp int64, token string) (*AvailableVsCurrencies, error) { date := time.Unix(timestamp, 0) date = date.UTC() - // TODO - ticker, err := w.db.FiatRatesFindTicker(&date, "", "") + ticker, err := w.db.FiatRatesFindTicker(&date, "", strings.ToLower(token)) if err != nil { return nil, NewAPIError(fmt.Sprintf("Error finding ticker: %v", err), false) } else if ticker == nil { diff --git a/blockbook.go b/blockbook.go index 304c6f66..37db2527 100644 --- a/blockbook.go +++ b/blockbook.go @@ -525,6 +525,7 @@ func onNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) { defer func() { if r := recover(); r != nil { glog.Error("onNewFiatRatesTicker recovered from panic: ", r) + debug.PrintStack() } }() for _, c := range callbacksOnNewFiatRatesTicker { diff --git a/db/fiat.go b/db/fiat.go index e4b57f79..9347aba0 100644 --- a/db/fiat.go +++ b/db/fiat.go @@ -26,6 +26,49 @@ type CurrencyRatesTicker struct { TokenRates map[string]float32 // rates of the tokens (identified by the address of the contract) against the base currency } +// Convert converts value in base currency to toCurrency +func (t *CurrencyRatesTicker) Convert(baseValue float64, toCurrency string) float64 { + rate, found := t.Rates[toCurrency] + if !found { + return 0 + } + return baseValue * float64(rate) +} + +// ConvertTokenToBase converts token value to base currency +func (t *CurrencyRatesTicker) ConvertTokenToBase(value float64, token string) float64 { + if t.TokenRates != nil { + rate, found := t.TokenRates[token] + if found { + return value * float64(rate) + } + } + return 0 +} + +// ConvertTokenToBase converts token value to toCurrency currency +func (t *CurrencyRatesTicker) ConvertToken(value float64, token string, toCurrency string) float64 { + baseValue := t.ConvertTokenToBase(value, token) + if baseValue > 0 { + return t.Convert(baseValue, toCurrency) + } + return 0 +} + +// TokenRateInCurrency return token rate in toCurrency currency +func (t *CurrencyRatesTicker) TokenRateInCurrency(token string, toCurrency string) float32 { + if t.TokenRates != nil { + rate, found := t.TokenRates[token] + if found { + baseRate, found := t.Rates[toCurrency] + if found { + return baseRate * rate + } + } + } + return 0 +} + func packTimestamp(t *time.Time) []byte { return []byte(t.UTC().Format(FiatRatesTimeFormat)) } @@ -117,6 +160,26 @@ func (d *RocksDB) FiatRatesStoreTicker(wb *gorocksdb.WriteBatch, ticker *Currenc return nil } +func isSuitableTicker(ticker *CurrencyRatesTicker, vsCurrency string, token string) bool { + if vsCurrency != "" { + if ticker.Rates == nil { + return false + } + if _, found := ticker.Rates[vsCurrency]; !found { + return false + } + } + if token != "" { + if ticker.TokenRates == nil { + return false + } + if _, found := ticker.TokenRates[token]; !found { + return false + } + } + return true +} + func getTickerFromIterator(it *gorocksdb.Iterator, vsCurrency string, token string) (*CurrencyRatesTicker, error) { timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data())) if err != nil { @@ -126,21 +189,8 @@ func getTickerFromIterator(it *gorocksdb.Iterator, vsCurrency string, token stri if err != nil { return nil, err } - if vsCurrency != "" { - if ticker.Rates == nil { - return nil, nil - } - if _, found := ticker.Rates[vsCurrency]; !found { - return nil, nil - } - } - if token != "" { - if ticker.TokenRates == nil { - return nil, nil - } - if _, found := ticker.TokenRates[token]; !found { - return nil, nil - } + if !isSuitableTicker(ticker, vsCurrency, token) { + return nil, nil } ticker.Timestamp = timeObj.UTC() return ticker, nil @@ -169,8 +219,8 @@ func (d *RocksDB) FiatRatesGetTicker(tickerTime *time.Time) (*CurrencyRatesTicke // FiatRatesFindTicker gets FiatRates data closest to the specified timestamp, of the base currency, vsCurrency or the token if specified func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time, vsCurrency string, token string) (*CurrencyRatesTicker, error) { tickersMux.Lock() - if currentTicker != nil && lastTickerInDB != nil { - if tickerTime.After(lastTickerInDB.Timestamp) { + if currentTicker != nil { + if !tickerTime.Before(currentTicker.Timestamp) || (lastTickerInDB != nil && tickerTime.After(lastTickerInDB.Timestamp)) { f := true if token != "" && currentTicker.TokenRates != nil { _, f = currentTicker.TokenRates[token] @@ -224,14 +274,17 @@ func (d *RocksDB) FiatRatesFindLastTicker(vsCurrency string, token string) (*Cur return nil, nil } -// FiatRatesGetCurrentTicker return current ticker -func (d *RocksDB) FiatRatesGetCurrentTicker(tickerTime *time.Time, token string) (*CurrencyRatesTicker, error) { +// FiatRatesGetCurrentTicker returns current ticker +func (d *RocksDB) FiatRatesGetCurrentTicker(vsCurrency string, token string) (*CurrencyRatesTicker, error) { tickersMux.Lock() defer tickersMux.Unlock() - return currentTicker, nil + if currentTicker != nil && isSuitableTicker(currentTicker, vsCurrency, token) { + return currentTicker, nil + } + return nil, nil } -// FiatRatesCurrentTicker return current ticker +// FiatRatesCurrentTicker sets current ticker func (d *RocksDB) FiatRatesSetCurrentTicker(t *CurrencyRatesTicker) { tickersMux.Lock() defer tickersMux.Unlock() diff --git a/db/fiat_test.go b/db/fiat_test.go index b2c2e7cf..1c90c606 100644 --- a/db/fiat_test.go +++ b/db/fiat_test.go @@ -157,6 +157,24 @@ func TestRocksTickers(t *testing.T) { t.Errorf("Ticker %v found unexpectedly for aud vsCurrency", ticker) } + ticker, err = d.FiatRatesGetCurrentTicker("", "") + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker != nil { + t.Errorf("FiatRatesGetCurrentTicker %v found unexpectedly", ticker) + } + + d.FiatRatesSetCurrentTicker(ticker1) + ticker, err = d.FiatRatesGetCurrentTicker("", "") + if err != nil { + t.Errorf("TestRocksTickers err: %+v", err) + } else if ticker == nil { + t.Errorf("Ticker not found") + } else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker1.Timestamp.Format(FiatRatesTimeFormat) { + t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp) + } + + d.FiatRatesSetCurrentTicker(nil) } func Test_packUnpackCurrencyRatesTicker(t *testing.T) { @@ -208,3 +226,64 @@ func Test_packUnpackCurrencyRatesTicker(t *testing.T) { }) } } + +func TestCurrencyRatesTicker_ConvertToken(t *testing.T) { + ticker := &CurrencyRatesTicker{ + Rates: map[string]float32{ + "usd": 2129.987654321, + "eur": 1332.12345678, + }, + TokenRates: map[string]float32{ + "0x82df128257a7d7556262e1ab7f1f639d9775b85e": 0.4092341123, + "0x6b175474e89094c44da98b954eedeac495271d0f": 12.32323232323232, + "0xdac17f958d2ee523a2206206994597c13d831ec7": 1332421341235.51234, + }, + } + type args struct { + baseValue float64 + toCurrency string + } + tests := []struct { + name string + value float64 + token string + toCurrency string + want float64 + }{ + { + name: "usd 0x82df128257a7d7556262e1ab7f1f639d9775b85e", + value: 10, + token: "0x82df128257a7d7556262e1ab7f1f639d9775b85e", + toCurrency: "usd", + want: 8716.635514874506, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec7", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec7", + toCurrency: "eur", + want: 4.104216071804417e+16, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec8", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec8", + toCurrency: "eur", + want: 0, + }, + { + name: "eur 0xdac17f958d2ee523a2206206994597c13d831ec7", + value: 23.123, + token: "0xdac17f958d2ee523a2206206994597c13d831ec7", + toCurrency: "czk", + want: 0, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ticker.ConvertToken(tt.value, tt.token, tt.toCurrency); got != tt.want { + t.Errorf("CurrencyRatesTicker.ConvertToken() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/fiat/coingecko.go b/fiat/coingecko.go index d800d654..ece706d8 100644 --- a/fiat/coingecko.go +++ b/fiat/coingecko.go @@ -26,6 +26,7 @@ type Coingecko struct { timeFormat string httpClient *http.Client db *db.RocksDB + updatingCurrent bool updatingTokens bool } @@ -42,13 +43,17 @@ type coinsListItem struct { // coinList https://api.coingecko.com/api/v3/coins/list type coinList []coinsListItem -type marketPoint [2]float32 +type marketPoint [2]float64 type marketChartPrices struct { Prices []marketPoint `json:"prices"` } // NewCoinGeckoDownloader creates a coingecko structure that implements the RatesDownloaderInterface -func NewCoinGeckoDownloader(db *db.RocksDB, url string, coin string, platformIdentifier string, platformVsCurrency string, timeFormat string, throttlingDelayMs int) RatesDownloaderInterface { +func NewCoinGeckoDownloader(db *db.RocksDB, url string, coin string, platformIdentifier string, platformVsCurrency string, timeFormat string, throttleDown bool) RatesDownloaderInterface { + var throttlingDelayMs int + if throttleDown { + throttlingDelayMs = 100 + } httpTimeoutSeconds := 15 * time.Second return &Coingecko{ url: url, @@ -95,13 +100,13 @@ func (cg *Coingecko) makeReq(url string) ([]byte, error) { if err == nil { return resp, err } - if err.Error() != "error code: 1015" { + if err.Error() != "error code: 1015" && !strings.Contains(strings.ToLower(err.Error()), "exceeded the rate limit") { glog.Errorf("Coingecko makeReq %v error %v", url, err) return nil, err } - // if there is a throttling error, wait 70 seconds and retry - glog.Errorf("Coingecko makeReq %v error %v, will retry in 70 seconds", url, err) - time.Sleep(70 * time.Second) + // if there is a throttling error, wait 60 seconds and retry + glog.Errorf("Coingecko makeReq %v error %v, will retry in 60 seconds", url, err) + time.Sleep(60 * time.Second) } } @@ -219,6 +224,9 @@ func (cg *Coingecko) platformIds() error { } func (cg *Coingecko) CurrentTickers() (*db.CurrencyRatesTicker, error) { + cg.updatingCurrent = true + defer func() { cg.updatingCurrent = false }() + var newTickers = db.CurrencyRatesTicker{} if vsCurrencies == nil { @@ -290,13 +298,12 @@ func (cg *Coingecko) getHistoricalTicker(tickersToUpdate map[uint]*db.CurrencyRa warningLogged := false for _, p := range mc.Prices { var timestamp uint - if p[0] > 100000000000 { + timestamp = uint(p[0]) + if timestamp > 100000000000 { // convert timestamp from milliseconds to seconds - timestamp = uint(p[0] / 1000) - } else { - timestamp = uint(p[0]) + timestamp /= 1000 } - rate := p[1] + rate := float32(p[1]) if timestamp%(24*3600) == 0 && timestamp != 0 && rate != 0 { // process only tickers for the whole day with non 0 value var found bool var ticker *db.CurrencyRatesTicker @@ -350,6 +357,15 @@ func (cg *Coingecko) storeTickers(tickersToUpdate map[uint]*db.CurrencyRatesTick return nil } +func (cg *Coingecko) throttleHistoricalDownload() { + // long delay next request to avoid throttling if downloading current tickers at the same time + delay := 1 + if cg.updatingCurrent { + delay = 600 + } + time.Sleep(cg.throttlingDelay * time.Duration(delay)) +} + // UpdateHistoricalTickers gets historical tickers for the main crypto currency func (cg *Coingecko) UpdateHistoricalTickers() error { tickersToUpdate := make(map[uint]*db.CurrencyRatesTicker) @@ -371,7 +387,7 @@ func (cg *Coingecko) UpdateHistoricalTickers() error { glog.Errorf("getHistoricalTicker %s-%s %v", cg.coin, currency, err) } if req { - time.Sleep(cg.throttlingDelay) + cg.throttleHistoricalDownload() } } @@ -413,8 +429,7 @@ func (cg *Coingecko) UpdateHistoricalTokenTickers() error { glog.Infof("Coingecko updated %d of %d token tickers", count, len(platformIds)) } if req { - // long delay next request to avoid throttling - time.Sleep(cg.throttlingDelay * 20) + cg.throttleHistoricalDownload() } } } diff --git a/fiat/fiat_rates.go b/fiat/fiat_rates.go index 8ac8fe07..37e0d835 100644 --- a/fiat/fiat_rates.go +++ b/fiat/fiat_rates.go @@ -56,12 +56,12 @@ func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, callb rd.db = db rd.callbackOnNewTicker = callback if apiType == "coingecko" { - throttlingDelayMs := 50 + throttle := true if callback == nil { // a small hack - in tests the callback is not used, therefore there is no delay slowing the test - throttlingDelayMs = 0 + throttle = false } - rd.downloader = NewCoinGeckoDownloader(db, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, rd.timeFormat, throttlingDelayMs) + rd.downloader = NewCoinGeckoDownloader(db, rdParams.URL, rdParams.Coin, rdParams.PlatformIdentifier, rdParams.PlatformVsCurrency, rd.timeFormat, throttle) } else { return nil, fmt.Errorf("NewFiatRatesDownloader: incorrect API type %q", apiType) } @@ -74,11 +74,14 @@ func (rd *RatesDownloader) Run() error { for { tickers, err := rd.downloader.CurrentTickers() - if err != nil && tickers != nil { + if err != nil || tickers == nil { glog.Error("FiatRatesDownloader: CurrentTickers error ", err) } else { rd.db.FiatRatesSetCurrentTicker(tickers) glog.Info("FiatRatesDownloader: CurrentTickers updated") + if rd.callbackOnNewTicker != nil { + rd.callbackOnNewTicker(tickers) + } } if time.Now().UTC().YearDay() != lastHistoricalTickers.YearDay() || time.Now().UTC().Year() != lastHistoricalTickers.Year() { err = rd.downloader.UpdateHistoricalTickers() @@ -86,20 +89,24 @@ func (rd *RatesDownloader) Run() error { glog.Error("FiatRatesDownloader: UpdateHistoricalTickers error ", err) } else { lastHistoricalTickers = time.Now().UTC() - glog.Info("FiatRatesDownloader: UpdateHistoricalTickers finished") - } - // UpdateHistoricalTokenTickers in a goroutine, it can take quite some time as there may be many tokens - go func() { - err := rd.downloader.UpdateHistoricalTokenTickers() - if err != nil { - glog.Error("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err) + ticker, err := rd.db.FiatRatesFindLastTicker("", "") + if err != nil || ticker == nil { + glog.Error("FiatRatesDownloader: FiatRatesFindLastTicker error ", err) } else { - lastHistoricalTickers = time.Now().UTC() - glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished") + glog.Infof("FiatRatesDownloader: UpdateHistoricalTickers finished, last ticker from %v", ticker.Timestamp) } - }() + // UpdateHistoricalTokenTickers in a goroutine, it can take quite some time as there may be many tokens + go func() { + err := rd.downloader.UpdateHistoricalTokenTickers() + if err != nil { + glog.Error("FiatRatesDownloader: UpdateHistoricalTokenTickers error ", err) + } else { + glog.Info("FiatRatesDownloader: UpdateHistoricalTokenTickers finished") + } + }() + } } - // next run on the + // wait for the next run with a slight random value to avoid too many request at the same time now := time.Now().Unix() next := now + rd.periodSeconds next -= next % rd.periodSeconds diff --git a/server/public.go b/server/public.go index bb8315c4..b92e0983 100644 --- a/server/public.go +++ b/server/public.go @@ -1205,7 +1205,8 @@ func (s *PublicServer) apiAvailableVsCurrencies(r *http.Request, apiVersion int) if err != nil { return nil, api.NewAPIError("Parameter \"timestamp\" is not a valid Unix timestamp.", true) } - result, err := s.api.GetAvailableVsCurrencies(timestamp) + token := strings.ToLower(r.URL.Query().Get("token")) + result, err := s.api.GetAvailableVsCurrencies(timestamp, token) return result, err } @@ -1219,11 +1220,12 @@ func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, if currency != "" { currencies = []string{currency} } + token := strings.ToLower(r.URL.Query().Get("token")) if block := r.URL.Query().Get("block"); block != "" { // Get tickers for specified block height or block hash s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-block"}).Inc() - result, err = s.api.GetFiatRatesForBlockID(block, currencies) + result, err = s.api.GetFiatRatesForBlockID(block, currencies, token) } else if timestampString := r.URL.Query().Get("timestamp"); timestampString != "" { // Get tickers for specified timestamp s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-date"}).Inc() @@ -1233,7 +1235,7 @@ func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, return nil, api.NewAPIError("Parameter 'timestamp' is not a valid Unix timestamp.", true) } - resultTickers, err := s.api.GetFiatRatesForTimestamps([]int64{timestamp}, currencies) + resultTickers, err := s.api.GetFiatRatesForTimestamps([]int64{timestamp}, currencies, token) if err != nil { return nil, err } @@ -1241,7 +1243,7 @@ func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, } else { // No parameters - get the latest available ticker s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-last"}).Inc() - result, err = s.api.GetCurrentFiatRates(currencies) + result, err = s.api.GetCurrentFiatRates(currencies, token) } if err != nil { return nil, err @@ -1259,6 +1261,7 @@ func (s *PublicServer) apiMultiTickers(r *http.Request, apiVersion int) (interfa if currency != "" { currencies = []string{currency} } + token := strings.ToLower(r.URL.Query().Get("token")) if timestampString := r.URL.Query().Get("timestamp"); timestampString != "" { // Get tickers for specified timestamp s.metrics.ExplorerViews.With(common.Labels{"action": "api-multi-tickers-date"}).Inc() @@ -1270,7 +1273,7 @@ func (s *PublicServer) apiMultiTickers(r *http.Request, apiVersion int) (interfa return nil, api.NewAPIError("Parameter 'timestamp' does not contain a valid Unix timestamp.", true) } } - resultTickers, err := s.api.GetFiatRatesForTimestamps(t, currencies) + resultTickers, err := s.api.GetFiatRatesForTimestamps(t, currencies, token) if err != nil { return nil, err } diff --git a/server/public_ethereumtype_test.go b/server/public_ethereumtype_test.go index 93367959..9f1ac6f0 100644 --- a/server/public_ethereumtype_test.go +++ b/server/public_ethereumtype_test.go @@ -85,6 +85,42 @@ func httpTestsEthereumType(t *testing.T, ts *httptest.Server) { `{"txid":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","vin":[{"n":0,"addresses":["0x20cD153de35D469BA46127A0C8F18626b59a256A"],"isAddress":true}],"vout":[{"value":"0","n":0,"addresses":["0x4af4114F73d1c1C903aC9E0361b379D1291808A2"],"isAddress":true}],"blockHeight":-1,"confirmations":0,"blockTime":0,"value":"0","fees":"2081000000000000","rbf":true,"coinSpecificData":{"tx":{"nonce":"0xd0","gasPrice":"0x9502f9000","gas":"0x130d5","to":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","value":"0x0","input":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","hash":"0xa9cd088aba2131000da6f38a33c20169baee476218deea6b78720700b895b101","blockNumber":"0x41eee8","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","transactionIndex":"0x0"},"internalData":{"type":0,"transfers":[{"type":1,"from":"9f4981531fda132e83c44680787dfa7ee31e4f8d","to":"4af4114f73d1c1c903ac9e0361b379d1291808a2","value":1000000},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"9f4981531fda132e83c44680787dfa7ee31e4f8d","value":1000001},{"type":0,"from":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","to":"3e3a3d69dc66ba10737f531ed088954a9ec89d97","value":1000002}],"Error":""},"receipt":{"gasUsed":"0xcb39","status":"0x1","logs":[{"address":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x00000000000000000000000020cd153de35d469ba46127a0c8f18626b59a256a","0x000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f"],"data":"0x00000000000000000000000000000000000000000000021e19e0c9bab2400000"}]}},"tokenTransfers":[{"type":"ERC20","from":"0x20cD153de35D469BA46127A0C8F18626b59a256A","to":"0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f","token":"0x4af4114F73d1c1C903aC9E0361b379D1291808A2","name":"Contract 74","symbol":"S74","decimals":12,"value":"10000000000000000000000"}],"ethereumSpecific":{"status":1,"nonce":208,"gasLimit":78037,"gasUsed":52025,"gasPrice":"40000000000","data":"0xa9059cbb000000000000000000000000555ee11fbddc0e49a9bab358a8941ad95ffdb48f00000000000000000000000000000000000000000000021e19e0c9bab2400000","parsedData":{"methodId":"0xa9059cbb","name":"Transfer","function":"transfer(address, uint256)","params":[{"type":"address","values":["0x555Ee11FBDDc0E49A9bAB358A8941AD95fFDB48f"]},{"type":"uint256","values":["10000000000000000000000"]}]}},"addressAliases":{"0x20cD153de35D469BA46127A0C8F18626b59a256A":{"Type":"ENS","Alias":"address20.eth"},"0x4af4114F73d1c1C903aC9E0361b379D1291808A2":{"Type":"Contract","Alias":"Contract 74"}}}`, }, }, + { + name: "apiFiatRates get rate by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"ts":1574344800,"rates":{"usd":7814.5}}`, + }, + }, + { + name: "apiFiatRates get token rate by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000&token=0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"ts":1574344800,"rates":{"usd":6251.6}}`, + }, + }, + { + name: "apiFiatRates get token rate by timestamp for all currencies", + r: newGetRequest(ts.URL + "/api/v2/tickers?timestamp=1574340000&token=0xA4DD6Bc15Be95Af55f0447555c8b6aA3088562f3"), + status: http.StatusBadRequest, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"error":"Rates for token only for a single currency"}`, + }, + }, + { + name: "apiFiatRates get token rate for unknown token by timestamp", + r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd×tamp=1574340000&token=0xFFFFFFFFFFe95Af55f0447555c8b6aA3088562f3"), + status: http.StatusOK, + contentType: "application/json; charset=utf-8", + body: []string{ + `{"ts":1574340000,"rates":{"usd":-1}}`, + }, + }, } performHttpTests(tests, t, ts) @@ -103,6 +139,64 @@ func initEthereumTypeDB(d *db.RocksDB) error { return d.WriteBatch(wb) } +// initTestFiatRatesEthereumType initializes test data for /api/v2/tickers endpoint +func initTestFiatRatesEthereumType(d *db.RocksDB) error { + if err := insertFiatRate("20180320020000", map[string]float32{ + "usd": 2000.0, + "eur": 1300.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2000.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 123.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180320030000", map[string]float32{ + "usd": 2001.0, + "eur": 1301.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2001.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 199.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180320040000", map[string]float32{ + "usd": 2002.0, + "eur": 1302.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2002.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 99.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20180321055521", map[string]float32{ + "usd": 2003.0, + "eur": 1303.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 2003.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 101.0, + }, d); err != nil { + return err + } + if err := insertFiatRate("20191121140000", map[string]float32{ + "usd": 7814.5, + "eur": 7100.0, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 7814.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 499.0, + "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 0.8, + }, d); err != nil { + return err + } + return insertFiatRate("20191121143015", map[string]float32{ + "usd": 7914.5, + "eur": 7134.1, + }, map[string]float32{ + "0xdac17f958d2ee523a2206206994597c13d831ec7": 7914.1, + "0x2260fac5e5542a773aa44fbcfedf7c193bc2c599": 599.0, + "0xa4dd6bc15be95af55f0447555c8b6aa3088562f3": 1.2, + }, d) +} + func Test_PublicServer_EthereumType(t *testing.T) { parser := eth.NewEthereumParser(1, true) chain, err := dbtestdata.NewFakeBlockChainEthereumType(parser) diff --git a/server/public_test.go b/server/public_test.go index c643caca..82f66a0c 100644 --- a/server/public_test.go +++ b/server/public_test.go @@ -75,14 +75,18 @@ func setupRocksDB(parser bchain.BlockChainParser, chain bchain.BlockChain, t *te if err := d.ConnectBlock(block2); err != nil { t.Fatal(err) } - if err := initTestFiatRates(d); err != nil { - t.Fatal(err) - } is.FinishedSync(block2.Height) if parser.GetChainType() == bchain.ChainEthereumType { + if err := initTestFiatRatesEthereumType(d); err != nil { + t.Fatal(err) + } if err := initEthereumTypeDB(d); err != nil { t.Fatal(err) } + } else { + if err := initTestFiatRates(d); err != nil { + t.Fatal(err) + } } return d, is, tmp } @@ -162,14 +166,15 @@ func newPostRequest(u string, body string) *http.Request { return r } -func insertFiatRate(date string, rates map[string]float32, d *db.RocksDB) error { +func insertFiatRate(date string, rates map[string]float32, tokenRates map[string]float32, d *db.RocksDB) error { convertedDate, err := db.FiatRatesConvertDate(date) if err != nil { return err } ticker := &db.CurrencyRatesTicker{ - Timestamp: *convertedDate, - Rates: rates, + Timestamp: *convertedDate, + Rates: rates, + TokenRates: tokenRates, } wb := gorocksdb.NewWriteBatch() defer wb.Destroy() @@ -184,37 +189,37 @@ func initTestFiatRates(d *db.RocksDB) error { if err := insertFiatRate("20180320020000", map[string]float32{ "usd": 2000.0, "eur": 1300.0, - }, d); err != nil { + }, nil, d); err != nil { return err } if err := insertFiatRate("20180320030000", map[string]float32{ "usd": 2001.0, "eur": 1301.0, - }, d); err != nil { + }, nil, d); err != nil { return err } if err := insertFiatRate("20180320040000", map[string]float32{ "usd": 2002.0, "eur": 1302.0, - }, d); err != nil { + }, nil, d); err != nil { return err } if err := insertFiatRate("20180321055521", map[string]float32{ "usd": 2003.0, "eur": 1303.0, - }, d); err != nil { + }, nil, d); err != nil { return err } if err := insertFiatRate("20191121140000", map[string]float32{ "usd": 7814.5, "eur": 7100.0, - }, d); err != nil { + }, nil, d); err != nil { return err } return insertFiatRate("20191121143015", map[string]float32{ "usd": 7914.5, "eur": 7134.1, - }, d) + }, nil, d) } type httpTests struct { @@ -1332,7 +1337,7 @@ func websocketTestsBitcoinType(t *testing.T, ts *httptest.Server) { "currencies": []string{"does-not-exist"}, }, }, - want: `{"id":"21","data":{"ts":1574346615,"rates":{"does-not-exist":-1}}}`, + want: `{"id":"21","data":{"error":{"message":"No tickers found!"}}}`, }, { name: "websocket getFiatRatesForTimestamps missing date", diff --git a/server/websocket.go b/server/websocket.go index b84bdf1a..17ea76a3 100644 --- a/server/websocket.go +++ b/server/websocket.go @@ -56,6 +56,11 @@ type websocketChannel struct { addrDescs []string // subscribed address descriptors as strings } +type fiatRatesSubscription struct { + Currency string `json:"currency"` + Tokens []string `json:"tokens"` +} + // WebsocketServer is a handle to websocket server type WebsocketServer struct { socket *websocket.Conn @@ -77,6 +82,7 @@ type WebsocketServer struct { addressSubscriptions map[string]map[*websocketChannel]string addressSubscriptionsLock sync.Mutex fiatRatesSubscriptions map[string]map[*websocketChannel]string + fiatRatesTokenSubscriptions map[*websocketChannel][]string fiatRatesSubscriptionsLock sync.Mutex } @@ -110,6 +116,7 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain. newTransactionSubscriptions: make(map[*websocketChannel]string), addressSubscriptions: make(map[string]map[*websocketChannel]string), fiatRatesSubscriptions: make(map[string]map[*websocketChannel]string), + fiatRatesTokenSubscriptions: make(map[*websocketChannel][]string), } return s, nil } @@ -378,14 +385,16 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs return s.unsubscribeAddresses(c) }, "subscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { - r := struct { - Currency string `json:"currency"` - }{} + var r fiatRatesSubscription err = json.Unmarshal(req.Params, &r) if err != nil { return nil, err } - return s.subscribeFiatRates(c, strings.ToLower(r.Currency), req) + r.Currency = strings.ToLower(r.Currency) + for i := range r.Tokens { + r.Tokens[i] = strings.ToLower(r.Tokens[i]) + } + return s.subscribeFiatRates(c, &r, req) }, "unsubscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { return s.unsubscribeFiatRates(c) @@ -397,10 +406,11 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs "getCurrentFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { r := struct { Currencies []string `json:"currencies"` + Token string `json:"token"` }{} err = json.Unmarshal(req.Params, &r) if err == nil { - rv, err = s.getCurrentFiatRates(r.Currencies) + rv, err = s.getCurrentFiatRates(r.Currencies, r.Token) } return }, @@ -408,20 +418,22 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs r := struct { Timestamps []int64 `json:"timestamps"` Currencies []string `json:"currencies"` + Token string `json:"token"` }{} err = json.Unmarshal(req.Params, &r) if err == nil { - rv, err = s.getFiatRatesForTimestamps(r.Timestamps, r.Currencies) + rv, err = s.getFiatRatesForTimestamps(r.Timestamps, r.Currencies, r.Token) } return }, "getFiatRatesTickersList": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) { r := struct { - Timestamp int64 `json:"timestamp"` + Timestamp int64 `json:"timestamp"` + Token string `json:"token"` }{} err = json.Unmarshal(req.Params, &r) if err == nil { - rv, err = s.getAvailableVsCurrencies(r.Timestamp) + rv, err = s.getAvailableVsCurrencies(r.Timestamp, r.Token) } return }, @@ -799,14 +811,16 @@ func (s *WebsocketServer) doUnsubscribeFiatRates(c *websocketChannel) { delete(s.fiatRatesSubscriptions, fr) } } + delete(s.fiatRatesTokenSubscriptions, c) } // subscribeFiatRates subscribes all FiatRates subscriptions by this channel -func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, currency string, req *websocketReq) (res interface{}, err error) { +func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, d *fiatRatesSubscription, req *websocketReq) (res interface{}, err error) { s.fiatRatesSubscriptionsLock.Lock() defer s.fiatRatesSubscriptionsLock.Unlock() // unsubscribe all previous subscriptions s.doUnsubscribeFiatRates(c) + currency := d.Currency if currency == "" { currency = allFiatRates } @@ -816,6 +830,9 @@ func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, currency strin s.fiatRatesSubscriptions[currency] = as } as[c] = req.ID + if len(d.Tokens) != 0 { + s.fiatRatesTokenSubscriptions[c] = d.Tokens + } s.metrics.WebsocketSubscribes.With((common.Labels{"method": "subscribeFiatRates"})).Set(float64(len(s.fiatRatesSubscriptions))) return &subscriptionResponse{true}, nil } @@ -960,7 +977,7 @@ func (s *WebsocketServer) OnNewTx(tx *bchain.MempoolTx) { } } -func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float32) { +func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]float32, ticker *db.CurrencyRatesTicker) { as, ok := s.fiatRatesSubscriptions[currency] if ok && len(as) > 0 { data := struct { @@ -969,10 +986,34 @@ func (s *WebsocketServer) broadcastTicker(currency string, rates map[string]floa Rates: rates, } for c, id := range as { - c.DataOut(&websocketRes{ - ID: id, - Data: &data, - }) + var tokens []string + if ticker != nil { + tokens = s.fiatRatesTokenSubscriptions[c] + } + if len(tokens) > 0 { + dataWithTokens := struct { + Rates interface{} `json:"rates"` + TokenRates map[string]float32 `json:"tokenRates,omitempty"` + }{ + Rates: rates, + TokenRates: map[string]float32{}, + } + for _, token := range tokens { + rate := ticker.TokenRateInCurrency(token, currency) + if rate > 0 { + dataWithTokens.TokenRates[token] = rate + } + } + c.DataOut(&websocketRes{ + ID: id, + Data: &dataWithTokens, + }) + } else { + c.DataOut(&websocketRes{ + ID: id, + Data: &data, + }) + } } glog.Info("broadcasting new rates for currency ", currency, " to ", len(as), " channels") } @@ -983,22 +1024,22 @@ func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) { s.fiatRatesSubscriptionsLock.Lock() defer s.fiatRatesSubscriptionsLock.Unlock() for currency, rate := range ticker.Rates { - s.broadcastTicker(currency, map[string]float32{currency: rate}) + s.broadcastTicker(currency, map[string]float32{currency: rate}, ticker) } - s.broadcastTicker(allFiatRates, ticker.Rates) + s.broadcastTicker(allFiatRates, ticker.Rates, nil) } -func (s *WebsocketServer) getCurrentFiatRates(currencies []string) (interface{}, error) { - ret, err := s.api.GetCurrentFiatRates(currencies) +func (s *WebsocketServer) getCurrentFiatRates(currencies []string, token string) (interface{}, error) { + ret, err := s.api.GetCurrentFiatRates(currencies, strings.ToLower(token)) return ret, err } -func (s *WebsocketServer) getFiatRatesForTimestamps(timestamps []int64, currencies []string) (interface{}, error) { - ret, err := s.api.GetFiatRatesForTimestamps(timestamps, currencies) +func (s *WebsocketServer) getFiatRatesForTimestamps(timestamps []int64, currencies []string, token string) (interface{}, error) { + ret, err := s.api.GetFiatRatesForTimestamps(timestamps, currencies, strings.ToLower(token)) return ret, err } -func (s *WebsocketServer) getAvailableVsCurrencies(timestamp int64) (interface{}, error) { - ret, err := s.api.GetAvailableVsCurrencies(timestamp) +func (s *WebsocketServer) getAvailableVsCurrencies(timestamp int64, token string) (interface{}, error) { + ret, err := s.api.GetAvailableVsCurrencies(timestamp, strings.ToLower(token)) return ret, err } diff --git a/static/test-websocket.html b/static/test-websocket.html index ec7180b8..37ab4c75 100644 --- a/static/test-websocket.html +++ b/static/test-websocket.html @@ -7,6 +7,9 @@ Blockbook Websocket Test Page @@ -94,6 +97,12 @@ } }; } + function paramAsArray(name) { + const p = document.getElementById(name).value; + if(p) { + return p.split(",").map(s => s.trim()); + } + } function getInfo() { const method = 'getInfo'; @@ -166,7 +175,7 @@ const descriptor = document.getElementById('getBalanceHistoryDescriptor').value.trim(); const from = parseInt(document.getElementById("getBalanceHistoryFrom").value.trim()); const to = parseInt(document.getElementById("getBalanceHistoryTo").value.trim()); - const currencies = document.getElementById('getBalanceHistoryFiat').value.split(","); + const currencies = paramAsArray('getBalanceHistoryFiat'); const groupBy = parseInt(document.getElementById("getBalanceHistoryGroupBy").value); const method = 'getBalanceHistory'; const params = { @@ -207,7 +216,7 @@ function estimateFee() { try { - var blocks = document.getElementById('estimateFeeBlocks').value.split(","); + var blocks = paramAsArray('estimateFeeBlocks'); var specific = document.getElementById('estimateFeeSpecific').value.trim(); if (specific) { // example for bitcoin type: {"conservative": false,"txsize":1234} @@ -299,8 +308,7 @@ function subscribeAddresses() { const method = 'subscribeAddresses'; - var addresses = document.getElementById('subscribeAddressesName').value.split(","); - addresses = addresses.map(s => s.trim()); + var addresses = paramAsArray('subscribeAddressesName'); const params = { addresses }; @@ -329,12 +337,14 @@ function getFiatRatesForTimestamps() { const method = 'getFiatRatesForTimestamps'; - var timestamps = document.getElementById('getFiatRatesForTimestampsList').value.split(","); - var currencies = document.getElementById('getFiatRatesForTimestampsCurrency').value.split(","); + var timestamps = paramAsArray('getFiatRatesForTimestampsList'); + var currencies = paramAsArray('getFiatRatesForTimestampsCurrency'); + var token = document.getElementById('getFiatRatesForTimestampsToken').value; timestamps = timestamps.map(Number); const params = { timestamps, - 'currencies': currencies + 'currencies': currencies, + token, }; send(method, params, function (result) { document.getElementById('getFiatRatesForTimestampsResult').innerText = JSON.stringify(result).replace(/,/g, ", "); @@ -343,9 +353,11 @@ function getCurrentFiatRates() { const method = 'getCurrentFiatRates'; - var currencies = document.getElementById('getCurrentFiatRatesCurrency').value.split(","); + var currencies = paramAsArray('getCurrentFiatRatesCurrency'); + var token = document.getElementById('getCurrentFiatRatesToken').value; const params = { - "currencies": currencies + "currencies": currencies, + token, }; send(method, params, function (result) { document.getElementById('getCurrentFiatRatesResult').innerText = JSON.stringify(result).replace(/,/g, ", "); @@ -355,9 +367,11 @@ function getFiatRatesTickersList() { const method = 'getFiatRatesTickersList'; var timestamp = document.getElementById('getFiatRatesTickersListDate').value; + var token = document.getElementById('getFiatRatesTickersToken').value; timestamp = parseInt(timestamp); const params = { timestamp, + token, }; send(method, params, function (result) { document.getElementById('getFiatRatesTickersListResult').innerText = JSON.stringify(result).replace(/,/g, ", "); @@ -367,8 +381,10 @@ function subscribeNewFiatRatesTicker() { const method = 'subscribeFiatRates'; var currency = document.getElementById('subscribeFiatRatesCurrency').value; + var tokens = paramAsArray('subscribeFiatRatesTokens'); const params = { - "currency": currency + "currency": currency, + tokens, }; if (subscribeNewFiatRatesTickerId) { delete subscriptions[subscribeNewFiatRatesTickerId]; @@ -473,7 +489,7 @@
-
+
@@ -509,7 +525,7 @@
-
+
@@ -524,7 +540,7 @@
-
+
@@ -573,6 +589,9 @@
+
+ +
@@ -584,17 +603,23 @@
+
+ +
- +
-
+
+
+ +
@@ -645,15 +670,18 @@
-
- -
-
- +
+
+
+ +
+
+ +