Return token exchange rates via API

This commit is contained in:
Martin Boehm 2022-08-09 00:08:31 +02:00 committed by Martin
parent f5b179d5c2
commit 3f5980abdb
11 changed files with 488 additions and 133 deletions

View File

@ -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 {

View File

@ -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 {

View File

@ -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()

View File

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

View File

@ -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()
}
}
}

View File

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

View File

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

View File

@ -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&timestamp=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&timestamp=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&timestamp=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)

View File

@ -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",

View File

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

View File

@ -7,6 +7,9 @@
<style>
.row {
margin-top: 1%;
}
::placeholder {
color: #ccc !important;
}
</style>
<title>Blockbook Websocket Test Page</title>
@ -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 @@
<div class="col-8">
<div class="row" style="margin: 0;">
<input type="text" placeholder="descriptor" class="form-control" id="getAccountUtxoDescriptor" value="0xba98d6a5ac827632e3457de7512d211e4ff7e8bd">
</div>
</div>
</div>
<div class="col form-inline"></div>
</div>
@ -509,7 +525,7 @@
<div class="col-8">
<div class="row" style="margin: 0;">
<input type="text" placeholder="txid" class="form-control" id="getTransactionTxid" value="0xb266c89f9bfefa4aa2fca4e65b7d6c918d5407f464be781c2803f3546d34a574">
</div>
</div>
</div>
<div class="col form-inline"></div>
</div>
@ -524,7 +540,7 @@
<div class="col-8">
<div class="row" style="margin: 0;">
<input type="text" placeholder="txid" class="form-control" id="getTransactionSpecificTxid" value="0xb266c89f9bfefa4aa2fca4e65b7d6c918d5407f464be781c2803f3546d34a574">
</div>
</div>
</div>
<div class="col form-inline"></div>
</div>
@ -573,6 +589,9 @@
<div class="col-7">
<input type="text" class="form-control" id="getFiatRatesForTimestampsList" value="1575288000,1575550800">
</div>
<div class="col-5 offset-3">
<input type="text" class="form-control" id="getFiatRatesForTimestampsToken" value="" placeholder="Token address">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesForTimestampsResult"></div>
@ -584,17 +603,23 @@
<div class="col-1">
<input type="text" class="form-control" id="getCurrentFiatRatesCurrency" placeholder="usd">
</div>
<div class="col-5">
<input type="text" class="form-control" id="getCurrentFiatRatesToken" value="" placeholder="Token address">
</div>
</div>
<div class="row">
<div class="col" id="getCurrentFiatRatesResult"></div>
</div>
<div class="row">
<div class="col-2">
<input class="btn btn-secondary" type="button" value="get fiat rates tickers" onclick="getFiatRatesTickersList()">
<input class="btn btn-secondary" type="button" value="get available tickers" onclick="getFiatRatesTickersList()">
</div>
<div class="col-8">
<div class="col-4">
<input type="text" class="form-control" id="getFiatRatesTickersListDate" value="1576591569" placeholder="Unix timestamp">
</div>
<div class="col-5">
<input type="text" class="form-control" id="getFiatRatesTickersToken" value="" placeholder="Token address">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesTickersListResult"></div>
@ -645,15 +670,18 @@
<div class="col" id="subscribeAddressesResult"></div>
</div>
<div class="row">
<div class="col-3">
<input class="btn btn-secondary" type="button" value="subscribe new fiat rates" onclick="subscribeNewFiatRatesTicker()">
</div>
<div class="col-1">
<span id="subscribeNewFiatRatesTickerId"></span>
<div class="col-2">
<input class="btn btn-secondary" type="button" value="subscribe fiat rates" onclick="subscribeNewFiatRatesTicker()">
</div>
<div class="col-1">
<input type="text" class="form-control" id="subscribeFiatRatesCurrency" value="usd">
</div>
<div class="col-8">
<input type="text" class="form-control" id="subscribeFiatRatesTokens" value="" placeholder="0xdAC17F958D2ee523a2206206994597C13D831ec7,0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2">
</div>
<div class="col-1">
<span id="subscribeNewFiatRatesTickerId"></span>
</div>
<div class="col-5">
<input class="btn btn-secondary" id="unsubscribeNewFiatRatesTickerButton" style="display: none;" type="button" value="unsubscribe" onclick="unsubscribeNewFiatRatesTicker()">
</div>