Add fiat rates functionality (#316)

* Add initial commit for fiat rates functionality

* templates.go: use bash from current user's environment

* bitcoinrpc.go: add FiatRates and FiatRatesParams to config

* blockbook.go: add initFiatRatesDownloader kickoff

* bitcoin.json: add coingecko API URL

* rockdb.go: add FindTicker and StoreTicker functions

* rocksdb_test.go: add a simple test for storing and getting FiatRate tickers

* rocksdb: add FindLastTicker and convertDate, make FindTicker return strings

* rocksdb: add ConvertDate function and CoinGeckoTicker struct, update tests

* blockbook.go, fiat: finalize the CoinGecko downloader

* coingecko.go: do not stop syncing when encountered an error

* rocksdb_test: fix the exported function name

* worker.go: make getBlockInfoFromBlockID a public function

* public.go: apiTickers kickoff

* rocksdb_test: fix the unittest comment

* coingecko.go: update comments

* blockbook.go, fiat: reword CoinGecko -> FiatRates, fix binary search upper bound, remove assignment of goroutine call result

* rename coingecko -> fiat_rates

* fiat_rates: export only the necessary methods

* blockbook.go: update log message

* bitcoinrpc.go: remove fiatRates settings

* use CurrencyRatesTicker structure everywhere, fix time format string, update tests, use UTC time

* add /api/v2/tickers tests, store rates as strings (json.Number)

* fiat_rates: add more tests, metrics and tickers-list endpoint, make the "currency" parameter mandatory

* public, worker: move FiatRates API logic to worker.go

* fiat_rates: add a future date test, fix comments, add more checks, store time as a pointer

* rocksdb_test: remove unneeded code

* fiat_rates: add a "ping" call to check server availability

* fiat_rates: do not return empty ticker, return nil instead if not found

add a test for non-existent ticker

* rocksdb_test: remove Sleep from tests

* worker.go: do not propagate all API errors to the client

* move InitTestFiatRates from rocksdb.go to public_test.go

* public.go: fix FiatRatesFindLastTicker result check

* fiat_rates: mock API server responses

* remove commented-out code

* fiat_rates: add comment explaining what periodSeconds attribute is used for

* websocket.go: implement fiatRates websocket endpoints & add tests

* fiatRates: add getFiatRatesTickersList websocket endpoint & test

* fiatRates: make websocket getFiatRatesByDate accept an array of dates, add more tests

* fiatRates: remove getFiatRatesForBlockID from websocket endpoints

* fiatRates: remove "if test", use custom startTime instead

Update tests and mock data

* fiatRates: finalize websocket functionality

add "date" parameter to TickerList

return data timestamps where needed

fix sync bugs (nil timestamp, duplicate save)

* fiatRates: add FiatRates configs for different coins

* worker.go: make GetBlockInfoFromBlockID private again

* fiatRates: wait & retry on errors, remove Ping function

* websocket.go: remove incorrect comment

* fiatRates: move coingecko-related code to a separate file, use interface

* fiatRates: if the new rates are the same as previous, try five more times, and only then store them

* coingecko: fix getting actual rates, add a timestamp parameter to get uncached responses

* vertcoin_testnet.json: remove fiat rates parameters

* fiat_rates: add timestamp to log message about skipping the repeating rates
balanceHistory
Vladyslav Burzakovskyy 2019-12-17 10:40:02 +01:00 committed by Martin
parent e2b34afb9c
commit f6111af5da
43 changed files with 1612 additions and 75 deletions

View File

@ -13,6 +13,7 @@ import (
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/golang/glog"
@ -972,6 +973,146 @@ func (w *Worker) GetBlocks(page int, blocksOnPage int) (*Blocks, error) {
return r, nil
}
// getFiatRatesResult checks if CurrencyRatesTicker contains all necessary data and returns formatted result
func (w *Worker) getFiatRatesResult(currency string, ticker *db.CurrencyRatesTicker) (*db.ResultTickerAsString, error) {
resultRates := make(map[string]json.Number)
timeFormatted := ticker.Timestamp.Format(db.FiatRatesTimeFormat)
// Check if both USD rate and the desired currency rate exist in the result
for _, currencySymbol := range []string{"usd", currency} {
if _, found := ticker.Rates[currencySymbol]; !found {
availableCurrencies := make([]string, 0, len(ticker.Rates))
for availableCurrency := range ticker.Rates {
availableCurrencies = append(availableCurrencies, string(availableCurrency))
}
sort.Strings(availableCurrencies) // sort to get deterministic results
availableCurrenciesString := strings.Join(availableCurrencies, ", ")
return nil, NewAPIError(fmt.Sprintf("Currency %q is not available for timestamp %s. Available currencies are: %s.", currency, timeFormatted, availableCurrenciesString), true)
}
resultRates[currencySymbol] = ticker.Rates[currencySymbol]
if currencySymbol == "usd" && currency == "usd" {
break
}
}
result := &db.ResultTickerAsString{
Timestamp: timeFormatted,
Rates: resultRates,
}
return result, nil
}
// GetFiatRatesForBlockID returns fiat rates for block height or block hash
func (w *Worker) GetFiatRatesForBlockID(bid string, currency string) (*db.ResultTickerAsString, error) {
if currency == "" {
return nil, NewAPIError("Missing or empty \"currency\" parameter", true)
}
ticker := &db.CurrencyRatesTicker{}
bi, err := w.getBlockInfoFromBlockID(bid)
if err != nil {
if err == bchain.ErrBlockNotFound {
return nil, NewAPIError(fmt.Sprintf("Block %v not found", bid), true)
}
return nil, NewAPIError(fmt.Sprintf("Block %v not found, error: %v", bid, err), false)
}
dbi := &db.BlockInfo{Time: bi.Time} // get timestamp from block
tm := time.Unix(dbi.Time, 0) // convert it to Time object
ticker, err = w.db.FiatRatesFindTicker(&tm)
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 (%s)", tm, currency), true)
}
result, err := w.getFiatRatesResult(currency, ticker)
if err != nil {
return nil, err
}
return result, nil
}
// GetCurrentFiatRates returns current fiat rates
func (w *Worker) GetCurrentFiatRates(currency string) (*db.ResultTickerAsString, error) {
if currency == "" {
return nil, NewAPIError("Missing or empty \"currency\" parameter", true)
}
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)
}
result, err := w.getFiatRatesResult(currency, ticker)
if err != nil {
return nil, err
}
return result, nil
}
// GetFiatRatesForDates returns fiat rates for each of the provided dates
func (w *Worker) GetFiatRatesForDates(dateStrings []string, currency string) (*db.ResultTickersAsString, error) {
if currency == "" {
return nil, NewAPIError("Missing or empty \"currency\" parameter", true)
} else if len(dateStrings) == 0 {
return nil, NewAPIError("No dates provided", true)
}
ret := &db.ResultTickersAsString{}
for _, dateString := range dateStrings {
date, err := db.FiatRatesConvertDate(dateString)
if err != nil {
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Error: fmt.Sprintf("%v", err)})
continue
}
ticker, err := w.db.FiatRatesFindTicker(date)
if err != nil {
glog.Errorf("Error finding ticker by date %v. Error: %v", dateString, err)
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Error: "Ticker not found."})
continue
} else if ticker == nil {
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Error: fmt.Sprintf("No tickers available for %s (%s)", date, currency)})
continue
}
result, err := w.getFiatRatesResult(currency, ticker)
if err != nil {
ret.Tickers = append(ret.Tickers, db.ResultTickerAsString{Error: fmt.Sprintf("%v", err)})
continue
}
ret.Tickers = append(ret.Tickers, *result)
}
return ret, nil
}
// GetFiatRatesTickersList returns the list of available fiatRates tickers
func (w *Worker) GetFiatRatesTickersList(dateString string) (*db.ResultTickerListAsString, error) {
if dateString == "" {
return nil, NewAPIError("Missing or empty \"date\" parameter", true)
}
date, err := db.FiatRatesConvertDate(dateString)
if err != nil {
return nil, err
}
ticker, err := w.db.FiatRatesFindTicker(date)
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 for date %v.", date), true)
}
keys := make([]string, 0, len(ticker.Rates))
for k := range ticker.Rates {
keys = append(keys, k)
}
sort.Strings(keys) // sort to get deterministic results
timeFormatted := ticker.Timestamp.Format(db.FiatRatesTimeFormat)
return &db.ResultTickerListAsString{
Timestamp: timeFormatted,
Tickers: keys,
}, nil
}
// getBlockInfoFromBlockID returns block info from block height or block hash
func (w *Worker) getBlockInfoFromBlockID(bid string) (*bchain.BlockInfo, error) {
// try to decide if passed string (bid) is block height or block hash
// if it's a number, must be less than int32

View File

@ -55,8 +55,8 @@ type Configuration struct {
XPubMagicSegwitP2sh uint32 `json:"xpub_magic_segwit_p2sh,omitempty"`
XPubMagicSegwitNative uint32 `json:"xpub_magic_segwit_native,omitempty"`
Slip44 uint32 `json:"slip44,omitempty"`
AlternativeEstimateFee string `json:"alternativeEstimateFee,omitempty"`
AlternativeEstimateFeeParams string `json:"alternativeEstimateFeeParams,omitempty"`
AlternativeEstimateFee string `json:"alternative_estimate_fee,omitempty"`
AlternativeEstimateFeeParams string `json:"alternative_estimate_fee_params,omitempty"`
MinimumCoinbaseConfirmations int `json:"minimumCoinbaseConfirmations,omitempty"`
}

View File

@ -6,9 +6,12 @@ import (
"blockbook/bchain/coins"
"blockbook/common"
"blockbook/db"
"blockbook/fiat"
"blockbook/server"
"context"
"encoding/json"
"flag"
"io/ioutil"
"log"
"math/rand"
"net/http"
@ -81,23 +84,24 @@ var (
)
var (
chanSyncIndex = make(chan struct{})
chanSyncMempool = make(chan struct{})
chanStoreInternalState = make(chan struct{})
chanSyncIndexDone = make(chan struct{})
chanSyncMempoolDone = make(chan struct{})
chanStoreInternalStateDone = make(chan struct{})
chain bchain.BlockChain
mempool bchain.Mempool
index *db.RocksDB
txCache *db.TxCache
metrics *common.Metrics
syncWorker *db.SyncWorker
internalState *common.InternalState
callbacksOnNewBlock []bchain.OnNewBlockFunc
callbacksOnNewTxAddr []bchain.OnNewTxAddrFunc
chanOsSignal chan os.Signal
inShutdown int32
chanSyncIndex = make(chan struct{})
chanSyncMempool = make(chan struct{})
chanStoreInternalState = make(chan struct{})
chanSyncIndexDone = make(chan struct{})
chanSyncMempoolDone = make(chan struct{})
chanStoreInternalStateDone = make(chan struct{})
chain bchain.BlockChain
mempool bchain.Mempool
index *db.RocksDB
txCache *db.TxCache
metrics *common.Metrics
syncWorker *db.SyncWorker
internalState *common.InternalState
callbacksOnNewBlock []bchain.OnNewBlockFunc
callbacksOnNewTxAddr []bchain.OnNewTxAddrFunc
callbacksOnNewFiatRatesTicker []fiat.OnNewFiatRatesTicker
chanOsSignal chan os.Signal
inShutdown int32
)
func init() {
@ -295,6 +299,7 @@ func mainWithExitCode() int {
// start full public interface
callbacksOnNewBlock = append(callbacksOnNewBlock, publicServer.OnNewBlock)
callbacksOnNewTxAddr = append(callbacksOnNewTxAddr, publicServer.OnNewTxAddr)
callbacksOnNewFiatRatesTicker = append(callbacksOnNewFiatRatesTicker, publicServer.OnNewFiatRatesTicker)
publicServer.ConnectFullPublicInterface()
}
@ -317,6 +322,8 @@ func mainWithExitCode() int {
}
if internalServer != nil || publicServer != nil || chain != nil {
// start fiat rates downloader only if not shutting down immediately
initFiatRatesDownloader(index, *blockchain)
waitForSignalAndShutdown(internalServer, publicServer, chain, 10*time.Second)
}
@ -521,6 +528,12 @@ func onNewBlockHash(hash string, height uint32) {
}
}
func onNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) {
for _, c := range callbacksOnNewFiatRatesTicker {
c(ticker)
}
}
func syncMempoolLoop() {
defer close(chanSyncMempoolDone)
glog.Info("syncMempoolLoop starting")
@ -650,3 +663,34 @@ func computeFeeStats(stopCompute chan os.Signal, blockFrom, blockTo int, db *db.
glog.Info("computeFeeStats finished in ", time.Since(start))
return err
}
func initFiatRatesDownloader(db *db.RocksDB, configfile string) {
data, err := ioutil.ReadFile(configfile)
if err != nil {
glog.Errorf("Error reading file %v, %v", configfile, err)
return
}
var config struct {
FiatRates string `json:"fiat_rates"`
FiatRatesParams string `json:"fiat_rates_params"`
}
err = json.Unmarshal(data, &config)
if err != nil {
glog.Errorf("Error parsing config file %v, %v", configfile, err)
return
}
if config.FiatRates == "" || config.FiatRatesParams == "" {
glog.Infof("FiatRates config (%v) is empty, so the functionality is disabled.", configfile)
} else {
fiatRates, err := fiat.NewFiatRatesDownloader(db, config.FiatRates, config.FiatRatesParams, nil, onNewFiatRatesTicker)
if err != nil {
glog.Errorf("NewFiatRatesDownloader Init error: %v", err)
return
}
glog.Infof("Starting %v FiatRates downloader...", config.FiatRates)
go fiatRates.Run()
}
}

View File

@ -98,7 +98,7 @@ func jsonToString(msg json.RawMessage) (string, error) {
}
func generateRPCAuth(user, pass string) (string, error) {
cmd := exec.Command("/bin/bash", "-c", "build/scripts/rpcauth.py \"$0\" \"$1\" | sed -n -e 2p", user, pass)
cmd := exec.Command("/usr/bin/env", "bash", "-c", "build/scripts/rpcauth.py \"$0\" \"$1\" | sed -n -e 2p", user, pass)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()

View File

@ -56,11 +56,14 @@
"block_addresses_to_keep": 300,
"xpub_magic": 76067358,
"slip44": 145,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-cash\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -252,11 +252,14 @@
"xpub_magic_segwit_p2sh": 77429938,
"xpub_magic_segwit_native": 78792518,
"slip44": 156,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin-gold\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "Jakub Matys",
"package_maintainer_email": "jakub.matys@satoshilabs.com"
}
}
}

View File

@ -59,8 +59,10 @@
"xpub_magic_segwit_p2sh": 77429938,
"xpub_magic_segwit_native": 78792518,
"additional_params": {
"alternativeEstimateFee": "whatthefee-disabled",
"alternativeEstimateFeeParams": "{\"url\": \"https://whatthefee.io/data.json\", \"periodSeconds\": 60}"
"alternative_estimate_fee": "whatthefee-disabled",
"alternative_estimate_fee_params": "{\"url\": \"https://whatthefee.io/data.json\", \"periodSeconds\": 60}",
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"
}
}
},

View File

@ -59,7 +59,10 @@
"xpub_magic_segwit_p2sh": 71979618,
"xpub_magic_segwit_native": 73342198,
"slip44": 1,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"
}
}
},
"meta": {

View File

@ -58,11 +58,14 @@
"block_addresses_to_keep": 300,
"xpub_magic": 50221772,
"slip44": 5,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dash\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT Admin",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -59,11 +59,14 @@
"xpub_magic_segwit_p2sh": 77429938,
"xpub_magic_segwit_native": 78792518,
"slip44": 20,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"digibyte\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "Martin Bohm",
"package_maintainer_email": "martin.bohm@satoshilabs.com"
}
}
}

View File

@ -60,11 +60,14 @@
"block_addresses_to_keep": 300,
"xpub_magic": 49990397,
"slip44": 3,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"dogecoin\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT Admin",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -63,7 +63,9 @@
"block_addresses_to_keep": 300,
"additional_params": {
"mempoolTxTimeoutHours": 48,
"queryBackendOnMempoolResync": true
"queryBackendOnMempoolResync": true,
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum-classic\", \"periodSeconds\": 60}"
}
}
},
@ -71,4 +73,4 @@
"package_maintainer": "IT",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -51,7 +51,9 @@
"block_addresses_to_keep": 300,
"additional_params": {
"mempoolTxTimeoutHours": 48,
"queryBackendOnMempoolResync": false
"queryBackendOnMempoolResync": false,
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\", \"periodSeconds\": 60}"
}
}
},

View File

@ -50,7 +50,9 @@
"block_addresses_to_keep": 300,
"additional_params": {
"mempoolTxTimeoutHours": 12,
"queryBackendOnMempoolResync": false
"queryBackendOnMempoolResync": false,
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"ethereum\", \"periodSeconds\": 60}"
}
}
},

View File

@ -59,11 +59,14 @@
"xpub_magic_segwit_p2sh": 28471030,
"xpub_magic_segwit_native": 78792518,
"slip44": 2,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"litecoin\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -64,11 +64,14 @@
"block_addresses_to_keep": 300,
"xpub_magic": 76067358,
"slip44": 7,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"namecoin\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT Admin",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -57,11 +57,14 @@
"xpub_magic_segwit_p2sh": 77429938,
"xpub_magic_segwit_native": 78792518,
"slip44": 28,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"vertcoin\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "Petr Kracik",
"package_maintainer_email": "petr.kracik@satoshilabs.com"
}
}
}

View File

@ -57,11 +57,14 @@
"block_addresses_to_keep": 300,
"xpub_magic": 76067358,
"slip44": 133,
"additional_params": {}
"additional_params": {
"fiat_rates": "coingecko",
"fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"zcash\", \"periodSeconds\": 60}"
}
}
},
"meta": {
"package_maintainer": "IT Admin",
"package_maintainer_email": "it@satoshilabs.com"
}
}
}

View File

@ -6,6 +6,7 @@ import (
"bytes"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
"math/big"
"os"
@ -30,6 +31,34 @@ const maxAddrDescLen = 1024
// when doing huge scan, it is better to close it and reopen from time to time to free the resources
const refreshIterator = 5000000
// FiatRatesTimeFormat is a format string for storing FiatRates timestamps in rocksdb
const FiatRatesTimeFormat = "20060102150405" // YYYYMMDDhhmmss
// CurrencyRatesTicker contains coin ticker data fetched from API
type CurrencyRatesTicker struct {
Timestamp *time.Time // return as unix timestamp in API
Rates map[string]json.Number
}
// ResultTickerAsString contains formatted CurrencyRatesTicker data
type ResultTickerAsString struct {
Timestamp string `json:"data_timestamp,omitempty"`
Rates map[string]json.Number `json:"rates,omitempty"`
Error string `json:"error,omitempty"`
}
// ResultTickersAsString contains a formatted CurrencyRatesTicker list
type ResultTickersAsString struct {
Tickers []ResultTickerAsString `json:"tickers"`
}
// ResultTickerListAsString contains formatted data about available currency tickers
type ResultTickerListAsString struct {
Timestamp string `json:"data_timestamp,omitempty"`
Tickers []string `json:"available_currencies"`
Error string `json:"error,omitempty"`
}
// RepairRocksDB calls RocksDb db repair function
func RepairRocksDB(name string) error {
glog.Infof("rocksdb: repair")
@ -77,6 +106,7 @@ const (
cfAddresses
cfBlockTxs
cfTransactions
cfFiatRates
// BitcoinType
cfAddressBalance
cfTxAddresses
@ -86,7 +116,7 @@ const (
// common columns
var cfNames []string
var cfBaseNames = []string{"default", "height", "addresses", "blockTxs", "transactions"}
var cfBaseNames = []string{"default", "height", "addresses", "blockTxs", "transactions", "fiatRates"}
// type specific columns
var cfNamesBitcoinType = []string{"addressBalance", "txAddresses"}
@ -99,7 +129,7 @@ func openDB(path string, c *gorocksdb.Cache, openFiles int) (*gorocksdb.DB, []*g
// from documentation: if most of your queries are executed using iterators, you shouldn't set bloom filter
optsAddresses := createAndSetDBOptions(0, c, openFiles)
// default, height, addresses, blockTxids, transactions
cfOptions := []*gorocksdb.Options{opts, opts, optsAddresses, opts, opts}
cfOptions := []*gorocksdb.Options{opts, opts, optsAddresses, opts, opts, opts}
// append type specific options
count := len(cfNames) - len(cfOptions)
for i := 0; i < count; i++ {
@ -146,6 +176,104 @@ func (d *RocksDB) closeDB() error {
return nil
}
// FiatRatesConvertDate checks if the date is in correct format and returns the Time object.
// Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD
func FiatRatesConvertDate(date string) (*time.Time, error) {
for format := FiatRatesTimeFormat; len(format) >= 8; format = format[:len(format)-2] {
convertedDate, err := time.Parse(format, date)
if err == nil {
return &convertedDate, nil
}
}
msg := "Date \"" + date + "\" does not match any of available formats. "
msg += "Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD"
return nil, errors.New(msg)
}
// FiatRatesStoreTicker stores ticker data at the specified time
func (d *RocksDB) FiatRatesStoreTicker(ticker *CurrencyRatesTicker) error {
if len(ticker.Rates) == 0 {
return errors.New("Error storing ticker: empty rates")
} else if ticker.Timestamp == nil {
return errors.New("Error storing ticker: empty timestamp")
}
ratesMarshalled, err := json.Marshal(ticker.Rates)
if err != nil {
glog.Error("Error marshalling ticker rates: ", err)
return err
}
timeFormatted := ticker.Timestamp.UTC().Format(FiatRatesTimeFormat)
err = d.db.PutCF(d.wo, d.cfh[cfFiatRates], []byte(timeFormatted), ratesMarshalled)
if err != nil {
glog.Error("Error storing ticker: ", err)
return err
}
return nil
}
// FiatRatesFindTicker gets FiatRates data closest to the specified timestamp
func (d *RocksDB) FiatRatesFindTicker(tickerTime *time.Time) (*CurrencyRatesTicker, error) {
ticker := &CurrencyRatesTicker{}
tickerTimeFormatted := tickerTime.UTC().Format(FiatRatesTimeFormat)
it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates])
defer it.Close()
for it.Seek([]byte(tickerTimeFormatted)); it.Valid(); it.Next() {
timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data()))
if err != nil {
glog.Error("FiatRatesFindTicker time parse error: ", err)
return nil, err
}
timeObj = timeObj.UTC()
ticker.Timestamp = &timeObj
err = json.Unmarshal(it.Value().Data(), &ticker.Rates)
if err != nil {
glog.Error("FiatRatesFindTicker error unpacking rates: ", err)
return nil, err
}
break
}
if err := it.Err(); err != nil {
glog.Error("FiatRatesFindTicker Iterator error: ", err)
return nil, err
}
if !it.Valid() {
return nil, nil // ticker not found
}
return ticker, nil
}
// FiatRatesFindLastTicker gets the last FiatRates record
func (d *RocksDB) FiatRatesFindLastTicker() (*CurrencyRatesTicker, error) {
ticker := &CurrencyRatesTicker{}
it := d.db.NewIteratorCF(d.ro, d.cfh[cfFiatRates])
defer it.Close()
for it.SeekToLast(); it.Valid(); it.Next() {
timeObj, err := time.Parse(FiatRatesTimeFormat, string(it.Key().Data()))
if err != nil {
glog.Error("FiatRatesFindTicker time parse error: ", err)
return nil, err
}
timeObj = timeObj.UTC()
ticker.Timestamp = &timeObj
err = json.Unmarshal(it.Value().Data(), &ticker.Rates)
if err != nil {
glog.Error("FiatRatesFindTicker error unpacking rates: ", err)
return nil, err
}
break
}
if err := it.Err(); err != nil {
glog.Error("FiatRatesFindLastTicker Iterator error: ", err)
return ticker, err
}
if !it.Valid() {
return nil, nil // ticker not found
}
return ticker, nil
}
// Close releases the RocksDB environment opened in NewRocksDB.
func (d *RocksDB) Close() error {
if d.db != nil {

View File

@ -9,6 +9,7 @@ import (
"blockbook/tests/dbtestdata"
"encoding/binary"
"encoding/hex"
"encoding/json"
"io/ioutil"
"math/big"
"os"
@ -16,6 +17,7 @@ import (
"sort"
"strings"
"testing"
"time"
vlq "github.com/bsm/go-vlq"
"github.com/juju/errors"
@ -1071,3 +1073,73 @@ func Test_packAddrBalance_unpackAddrBalance(t *testing.T) {
})
}
}
func TestRocksTickers(t *testing.T) {
d := setupRocksDB(t, &testBitcoinParser{
BitcoinParser: bitcoinTestnetParser(),
})
defer closeAndDestroyRocksDB(t, d)
// Test valid formats
for _, date := range []string{"20190130", "2019013012", "201901301250", "20190130125030"} {
_, err := FiatRatesConvertDate(date)
if err != nil {
t.Errorf("%v", err)
}
}
// Test invalid formats
for _, date := range []string{"01102019", "10201901", "", "abc", "20190130xxx"} {
_, err := FiatRatesConvertDate(date)
if err == nil {
t.Errorf("Wrongly-formatted date \"%v\" marked as valid!", date)
}
}
// Test storing & finding tickers
key, _ := time.Parse(FiatRatesTimeFormat, "20190627000000")
futureKey, _ := time.Parse(FiatRatesTimeFormat, "20190630000000")
ts1, _ := time.Parse(FiatRatesTimeFormat, "20190628000000")
ticker1 := &CurrencyRatesTicker{
Timestamp: &ts1,
Rates: map[string]json.Number{
"usd": "20000",
},
}
ts2, _ := time.Parse(FiatRatesTimeFormat, "20190629000000")
ticker2 := &CurrencyRatesTicker{
Timestamp: &ts2,
Rates: map[string]json.Number{
"usd": "30000",
},
}
d.FiatRatesStoreTicker(ticker1)
d.FiatRatesStoreTicker(ticker2)
ticker, err := d.FiatRatesFindTicker(&key) // should find the closest key (ticker1)
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)
}
ticker, err = d.FiatRatesFindLastTicker() // should find the last key (ticker2)
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker == nil {
t.Errorf("Ticker not found")
} else if ticker.Timestamp.Format(FiatRatesTimeFormat) != ticker2.Timestamp.Format(FiatRatesTimeFormat) {
t.Errorf("Incorrect ticker found. Expected: %v, found: %+v", ticker1.Timestamp, ticker.Timestamp)
}
ticker, err = d.FiatRatesFindTicker(&futureKey) // should not find anything
if err != nil {
t.Errorf("TestRocksTickers err: %+v", err)
} else if ticker != nil {
t.Errorf("Ticker found, but the timestamp is older than the last ticker entry.")
}
}

136
fiat/coingecko.go 100644
View File

@ -0,0 +1,136 @@
package fiat
import (
"blockbook/db"
"encoding/json"
"errors"
"io/ioutil"
"net/http"
"strconv"
"time"
"github.com/golang/glog"
)
// Coingecko is a structure that implements RatesDownloaderInterface
type Coingecko struct {
url string
coin string
httpTimeoutSeconds time.Duration
timeFormat string
}
// NewCoinGeckoDownloader creates a coingecko structure that implements the RatesDownloaderInterface
func NewCoinGeckoDownloader(url string, coin string, timeFormat string) RatesDownloaderInterface {
return &Coingecko{
url: url,
coin: coin,
httpTimeoutSeconds: 15 * time.Second,
timeFormat: timeFormat,
}
}
// makeRequest retrieves the response from Coingecko API at the specified date.
// If timestamp is nil, it fetches the latest market data available.
func (cg *Coingecko) makeRequest(timestamp *time.Time) ([]byte, error) {
requestURL := cg.url + "/coins/" + cg.coin
if timestamp != nil {
requestURL += "/history"
}
req, err := http.NewRequest("GET", requestURL, nil)
if err != nil {
glog.Errorf("Error creating a new request for %v: %v", requestURL, err)
return nil, err
}
req.Close = true
req.Header.Set("Content-Type", "application/json")
// Add query parameters
q := req.URL.Query()
// Add a unix timestamp to query parameters to get uncached responses
currentTimestamp := strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
q.Add("current_timestamp", currentTimestamp)
if timestamp == nil {
q.Add("market_data", "true")
q.Add("localization", "false")
q.Add("tickers", "false")
q.Add("community_data", "false")
q.Add("developer_data", "false")
} else {
timestampFormatted := timestamp.Format(cg.timeFormat)
q.Add("date", timestampFormatted)
}
req.URL.RawQuery = q.Encode()
client := &http.Client{
Timeout: cg.httpTimeoutSeconds,
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errors.New("Invalid response status: " + string(resp.Status))
}
bodyBytes, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return bodyBytes, nil
}
// GetData gets fiat rates from API at the specified date and returns a CurrencyRatesTicker
// If timestamp is nil, it will download the current fiat rates.
func (cg *Coingecko) getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error) {
dataTimestamp := timestamp
if timestamp == nil {
timeNow := time.Now()
dataTimestamp = &timeNow
}
dataTimestampUTC := dataTimestamp.UTC()
ticker := &db.CurrencyRatesTicker{Timestamp: &dataTimestampUTC}
bodyBytes, err := cg.makeRequest(timestamp)
if err != nil {
return nil, err
}
type FiatRatesResponse struct {
MarketData struct {
Prices map[string]json.Number `json:"current_price"`
} `json:"market_data"`
}
var data FiatRatesResponse
err = json.Unmarshal(bodyBytes, &data)
if err != nil {
glog.Errorf("Error parsing FiatRates response: %v", err)
return nil, err
}
ticker.Rates = data.MarketData.Prices
return ticker, nil
}
// MarketDataExists checks if there's data available for the specific timestamp.
func (cg *Coingecko) marketDataExists(timestamp *time.Time) (bool, error) {
resp, err := cg.makeRequest(timestamp)
if err != nil {
glog.Error("Error getting market data: ", err)
return false, err
}
type FiatRatesResponse struct {
MarketData struct {
Prices map[string]interface{} `json:"current_price"`
} `json:"market_data"`
}
var data FiatRatesResponse
err = json.Unmarshal(resp, &data)
if err != nil {
glog.Errorf("Error parsing Coingecko response: %v", err)
return false, err
}
return len(data.MarketData.Prices) != 0, nil
}

214
fiat/fiat_rates.go 100644
View File

@ -0,0 +1,214 @@
package fiat
import (
"blockbook/db"
"encoding/json"
"errors"
"fmt"
"reflect"
"time"
"github.com/golang/glog"
)
// OnNewFiatRatesTicker is used to send notification about a new FiatRates ticker
type OnNewFiatRatesTicker func(ticker *db.CurrencyRatesTicker)
// RatesDownloaderInterface provides method signatures for specific fiat rates downloaders
type RatesDownloaderInterface interface {
getTicker(timestamp *time.Time) (*db.CurrencyRatesTicker, error)
marketDataExists(timestamp *time.Time) (bool, error)
}
// RatesDownloader stores FiatRates API parameters
type RatesDownloader struct {
periodSeconds time.Duration
db *db.RocksDB
startTime *time.Time // a starting timestamp for tests to be deterministic (time.Now() for production)
timeFormat string
callbackOnNewTicker OnNewFiatRatesTicker
downloader RatesDownloaderInterface
}
// NewFiatRatesDownloader initiallizes the downloader for FiatRates API.
// If the startTime is nil, the downloader will start from the beginning.
func NewFiatRatesDownloader(db *db.RocksDB, apiType string, params string, startTime *time.Time, callback OnNewFiatRatesTicker) (*RatesDownloader, error) {
var rd = &RatesDownloader{}
type fiatRatesParams struct {
URL string `json:"url"`
Coin string `json:"coin"`
PeriodSeconds int `json:"periodSeconds"`
}
rdParams := &fiatRatesParams{}
err := json.Unmarshal([]byte(params), &rdParams)
if err != nil {
return nil, err
}
if rdParams.URL == "" || rdParams.PeriodSeconds == 0 {
return nil, errors.New("Missing parameters")
}
rd.timeFormat = "02-01-2006" // Layout string for FiatRates date formatting (DD-MM-YYYY)
rd.periodSeconds = time.Duration(rdParams.PeriodSeconds) * time.Second // Time period for syncing the latest market data
rd.db = db
rd.callbackOnNewTicker = callback
if startTime == nil {
timeNow := time.Now().UTC()
rd.startTime = &timeNow
} else {
rd.startTime = startTime // If startTime is nil, time.Now() will be used
}
if apiType == "coingecko" {
rd.downloader = NewCoinGeckoDownloader(rdParams.URL, rdParams.Coin, rd.timeFormat)
} else {
return nil, fmt.Errorf("NewFiatRatesDownloader: incorrect API type %q", apiType)
}
return rd, nil
}
// Run starts the FiatRates downloader. If there are tickers available, it continues from the last record.
// If there are no tickers, it finds the earliest market data available on API and downloads historical data.
// When historical data is downloaded, it continues to fetch the latest ticker prices.
func (rd *RatesDownloader) Run() error {
var timestamp *time.Time
// Check if there are any tickers stored in database
glog.Infof("Finding last available ticker...")
ticker, err := rd.db.FiatRatesFindLastTicker()
if err != nil {
glog.Errorf("RatesDownloader FindTicker error: %v", err)
return err
}
if ticker == nil {
// If no tickers found, start downloading from the beginning
glog.Infof("No tickers found! Looking up the earliest market data available on API and downloading from there.")
timestamp, err = rd.findEarliestMarketData()
if err != nil {
glog.Errorf("Error looking up earliest market data: %v", err)
return err
}
} else {
// If found, continue downloading data from the next day of the last available record
glog.Infof("Last available ticker: %v", ticker.Timestamp)
timestamp = ticker.Timestamp
}
err = rd.syncHistorical(timestamp)
if err != nil {
glog.Errorf("RatesDownloader syncHistorical error: %v", err)
return err
}
if err := rd.syncLatest(); err != nil {
glog.Errorf("RatesDownloader syncLatest error: %v", err)
return err
}
return nil
}
// FindEarliestMarketData uses binary search to find the oldest market data available on API.
func (rd *RatesDownloader) findEarliestMarketData() (*time.Time, error) {
minDateString := "03-01-2009"
minDate, err := time.Parse(rd.timeFormat, minDateString)
if err != nil {
glog.Error("Error parsing date: ", err)
return nil, err
}
maxDate := rd.startTime.Add(time.Duration(-24) * time.Hour) // today's historical tickers may not be ready yet, so set to yesterday
currentDate := maxDate
for {
var dataExists bool = false
for {
dataExists, err = rd.downloader.marketDataExists(&currentDate)
if err != nil {
glog.Errorf("Error checking if market data exists for date %v. Error: %v. Retrying in %v seconds.", currentDate, err, rd.periodSeconds)
timer := time.NewTimer(rd.periodSeconds)
<-timer.C
}
break
}
dateDiff := currentDate.Sub(minDate)
if dataExists {
if dateDiff < time.Hour*24 {
maxDate := time.Date(maxDate.Year(), maxDate.Month(), maxDate.Day(), 0, 0, 0, 0, maxDate.Location()) // truncate time to day
return &maxDate, nil
}
maxDate = currentDate
currentDate = currentDate.Add(-1 * dateDiff / 2)
} else {
minDate = currentDate
currentDate = currentDate.Add(maxDate.Sub(currentDate) / 2)
}
}
}
// syncLatest downloads the latest FiatRates data every rd.PeriodSeconds
func (rd *RatesDownloader) syncLatest() error {
timer := time.NewTimer(rd.periodSeconds)
var lastTickerRates map[string]json.Number = nil
sameTickerCounter := 0
for {
ticker, err := rd.downloader.getTicker(nil)
if err != nil {
// Do not exit on GET error, log it, wait and try again
glog.Errorf("syncLatest GetData error: %v", err)
<-timer.C
timer.Reset(rd.periodSeconds)
continue
}
if sameTickerCounter < 5 && reflect.DeepEqual(ticker.Rates, lastTickerRates) {
// If rates are the same as previous, do not store them
glog.Infof("syncLatest: ticker rates for %v are the same as previous, skipping...", ticker.Timestamp)
<-timer.C
timer.Reset(rd.periodSeconds)
sameTickerCounter++
continue
}
lastTickerRates = ticker.Rates
sameTickerCounter = 0
glog.Infof("syncLatest: storing ticker for %v", ticker.Timestamp)
err = rd.db.FiatRatesStoreTicker(ticker)
if err != nil {
// If there's an error storing ticker (like missing rates), log it, wait and try again
glog.Errorf("syncLatest StoreTicker error: %v", err)
} else if rd.callbackOnNewTicker != nil {
rd.callbackOnNewTicker(ticker)
}
<-timer.C
timer.Reset(rd.periodSeconds)
}
}
// syncHistorical downloads all the historical data since the specified timestamp till today,
// then continues to download the latest rates
func (rd *RatesDownloader) syncHistorical(timestamp *time.Time) error {
period := time.Duration(1) * time.Second
timer := time.NewTimer(period)
for {
if rd.startTime.Sub(*timestamp) < time.Duration(time.Hour*24) {
break
}
ticker, err := rd.downloader.getTicker(timestamp)
if err != nil {
// Do not exit on GET error, log it, wait and try again
glog.Errorf("syncHistorical GetData error: %v", err)
<-timer.C
timer.Reset(rd.periodSeconds)
continue
}
glog.Infof("syncHistorical: storing ticker for %v", ticker.Timestamp)
err = rd.db.FiatRatesStoreTicker(ticker)
if err != nil {
// If there's an error storing ticker (like missing rates), log it and continue to the next day
glog.Errorf("syncHistorical error storing ticker for %v: %v", timestamp, err)
}
*timestamp = timestamp.Add(time.Hour * 24) // go to the next day
<-timer.C
timer.Reset(period)
}
return nil
}

View File

@ -0,0 +1,176 @@
// +build unittest
package fiat
import (
"blockbook/bchain"
"blockbook/bchain/coins/btc"
"blockbook/common"
"blockbook/db"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/golang/glog"
"github.com/martinboehm/btcutil/chaincfg"
)
func TestMain(m *testing.M) {
// set the current directory to blockbook root so that ./static/ works
if err := os.Chdir(".."); err != nil {
glog.Fatal("Chdir error:", err)
}
c := m.Run()
chaincfg.ResetParams()
os.Exit(c)
}
func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *common.InternalState, string) {
tmp, err := ioutil.TempDir("", "testdb")
if err != nil {
t.Fatal(err)
}
d, err := db.NewRocksDB(tmp, 100000, -1, parser, nil)
if err != nil {
t.Fatal(err)
}
is, err := d.LoadInternalState("fakecoin")
if err != nil {
t.Fatal(err)
}
d.SetInternalState(is)
return d, is, tmp
}
func closeAndDestroyRocksDB(t *testing.T, db *db.RocksDB, dbpath string) {
// destroy db
if err := db.Close(); err != nil {
t.Fatal(err)
}
os.RemoveAll(dbpath)
}
type testBitcoinParser struct {
*btc.BitcoinParser
}
func bitcoinTestnetParser() *btc.BitcoinParser {
return btc.NewBitcoinParser(
btc.GetChainParams("test"),
&btc.Configuration{BlockAddressesToKeep: 1})
}
// getFiatRatesMockData reads a stub JSON response from a file and returns its content as string
func getFiatRatesMockData(dateParam string) (string, error) {
var filename string
if dateParam == "current" {
filename = "fiat/mock_data/current.json"
} else {
filename = "fiat/mock_data/" + dateParam + ".json"
}
mockFile, err := os.Open(filename)
if err != nil {
glog.Errorf("Cannot open file %v", filename)
return "", err
}
b, err := ioutil.ReadAll(mockFile)
if err != nil {
glog.Errorf("Cannot read file %v", filename)
return "", err
}
return string(b), nil
}
func TestFiatRates(t *testing.T) {
d, _, tmp := setupRocksDB(t, &testBitcoinParser{
BitcoinParser: bitcoinTestnetParser(),
})
defer closeAndDestroyRocksDB(t, d, tmp)
mockServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
var mockData string
if r.URL.Path == "/ping" {
w.WriteHeader(200)
} else if r.URL.Path == "/coins/bitcoin/history" {
date := r.URL.Query()["date"][0]
mockData, err = getFiatRatesMockData(date) // get stub rates by date
} else if r.URL.Path == "/coins/bitcoin" {
mockData, err = getFiatRatesMockData("current") // get "latest" stub rates
} else {
t.Errorf("Unknown URL path: %v", r.URL.Path)
}
if err != nil {
t.Errorf("Error loading stub data: %v", err)
}
fmt.Fprintln(w, mockData)
}))
defer mockServer.Close()
// real CoinGecko API
//configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"https://api.coingecko.com/api/v3\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}`
// mocked CoinGecko API
configJSON := `{"fiat_rates": "coingecko", "fiat_rates_params": "{\"url\": \"` + mockServer.URL + `\", \"coin\": \"bitcoin\", \"periodSeconds\": 60}"}`
type fiatRatesConfig struct {
FiatRates string `json:"fiat_rates"`
FiatRatesParams string `json:"fiat_rates_params"`
}
var config fiatRatesConfig
err := json.Unmarshal([]byte(configJSON), &config)
if err != nil {
t.Errorf("Error parsing config: %v", err)
}
if config.FiatRates == "" || config.FiatRatesParams == "" {
t.Errorf("Error parsing FiatRates config - empty parameter")
return
}
testStartTime := time.Date(2019, 11, 22, 16, 0, 0, 0, time.UTC)
fiatRates, err := NewFiatRatesDownloader(d, config.FiatRates, config.FiatRatesParams, &testStartTime, nil)
if err != nil {
t.Errorf("FiatRates init error: %v\n", err)
}
if config.FiatRates == "coingecko" {
timestamp, err := fiatRates.findEarliestMarketData()
if err != nil {
t.Errorf("Error looking up earliest market data: %v", err)
return
}
earliestTimestamp, _ := time.Parse(db.FiatRatesTimeFormat, "20130429000000")
if *timestamp != earliestTimestamp {
t.Errorf("Incorrect earliest available timestamp found. Wanted: %v, got: %v", earliestTimestamp, timestamp)
return
}
// After verifying that findEarliestMarketData works correctly,
// set the earliest available timestamp to 2 days ago for easier testing
*timestamp = fiatRates.startTime.Add(time.Duration(-24*2) * time.Hour)
err = fiatRates.syncHistorical(timestamp)
if err != nil {
t.Errorf("RatesDownloader syncHistorical error: %v", err)
return
}
ticker, err := fiatRates.downloader.getTicker(fiatRates.startTime)
if err != nil {
// Do not exit on GET error, log it, wait and try again
glog.Errorf("Sync GetData error: %v", err)
return
}
err = fiatRates.db.FiatRatesStoreTicker(ticker)
if err != nil {
glog.Errorf("Sync StoreTicker error %v", err)
return
}
}
}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.481,"brl":232.8687,"btc":1.0,"cad":117.617,"chf":108.7145,"cny":718.7368,"dkk":661.3731,"eur":88.6291,"gbp":74.9767,"hkd":903.2559,"idr":1130568.3956,"inr":6274.6092,"jpy":11364.3607,"krw":128625.969,"mxn":1412.9046,"myr":353.6681,"nzd":136.2101,"php":4792.6186,"pln":368.8928,"rub":3623.3519,"sek":758.5144,"sgd":143.534,"twd":3433.0342,"usd":117.0,"xag":4.9088,"xau":0.0808,"xdr":76.8864,"zar":1049.0856},"market_cap":{"aud":1248780934.15,"brl":2585343237.705,"btc":11102150.0,"cad":1305801576.55,"chf":1206964686.175,"cny":7979523764.12,"dkk":7342663362.165,"eur":983973562.5649999,"gbp":832402569.9049999,"hkd":10028082490.185,"idr":12551739913210.54,"inr":69661652529.78,"jpy":126168837145.505,"krw":1428024801733.35,"mxn":15686278804.89,"myr":3926476296.415,"nzd":1512224961.715,"php":53208370589.99,"pln":4095503199.52,"rub":40226996296.585,"sek":8421140645.96,"sgd":1593535998.1,"twd":38114060643.53,"usd":1298951550.0,"xag":54498233.92,"xau":897053.72,"xdr":853604345.7599999,"zar":11647105694.04},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":112.4208,"brl":233.1081,"btc":1.0,"cad":117.0104,"chf":108.4737,"cny":715.1351,"dkk":659.3659,"eur":88.4403,"gbp":74.4895,"hkd":899.8427,"idr":1128375.8759,"inr":6235.1253,"jpy":11470.4956,"krw":127344.157,"mxn":1401.3929,"myr":352.0832,"nzd":135.8774,"php":4740.2255,"pln":366.461,"rub":3602.9548,"sek":754.5925,"sgd":143.0844,"twd":3426.0044,"usd":116.79,"xag":4.9089,"xau":0.0807,"xdr":76.8136,"zar":1033.9804},"market_cap":{"aud":1249804517.76,"brl":2591509369.32,"btc":11117200.0,"cad":1300828018.88,"chf":1205923817.64,"cny":7950299933.72,"dkk":7330302583.48,"eur":983208503.1599998,"gbp":828114669.4000001,"hkd":10003731264.44,"idr":12544380287555.48,"inr":69317134985.16,"jpy":127519793684.32,"krw":1415710462200.4,"mxn":15579565147.88,"myr":3914179351.04,"nzd":1510576231.28,"php":52698034928.6,"pln":4074020229.2,"rub":40054769102.56,"sek":8388955741.0,"sgd":1590697891.68,"twd":38087576115.68,"usd":1298377788.0,"xag":54573223.08,"xau":897158.0399999999,"xdr":853952153.9199998,"zar":11494966902.88},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":126.3133,"brl":259.5964,"btc":1.0,"cad":126.0278,"chf":115.6713,"cny":748.4143,"dkk":695.3988,"eur":93.2043,"gbp":79.6307,"hkd":946.0816,"idr":1196029.7068,"inr":6892.8316,"jpy":12203.5904,"krw":137012.0733,"mxn":1551.7041,"myr":377.299,"nzd":152.0791,"php":5112.2715,"pln":396.1512,"rub":3891.7674,"sek":800.3999,"sgd":152.8465,"twd":3646.9125,"uah":987.805115156,"usd":121.309,"xag":5.3382,"xau":0.0868,"xdr":81.033,"zar":1200.5467},"market_cap":{"aud":1420386743.3035636,"brl":2919148539.142982,"btc":11244950.003709536,"cad":1417176310.0775046,"chf":1300717985.3640869,"cny":8415881385.561269,"dkk":7819724738.639607,"eur":1048077693.6307446,"gbp":895443240.2603929,"hkd":10638640291.429523,"idr":13449294255917.375,"inr":77509546725.9892,"jpy":137228763913.74965,"krw":1540693914163.0862,"mxn":17448835025.0511,"myr":4242708391.449604,"nzd":1710121876.1091428,"php":57487237422.88915,"pln":4454700437.909536,"rub":43762729839.06665,"sek":9000456858.474112,"sgd":1718751250.7419894,"twd":41009348730.40335,"uah":11107819133.33776,"usd":1364113640.0,"xag":60027792.10980224,"xau":976061.6603219877,"xdr":911212033.6505947,"zar":13500087618.61847},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.8239,"bdt":9961.3816372,"bhd":48.39643563999999,"bmd":128.38,"brl":272.2555,"btc":1.0,"cad":127.0763,"chf":111.3766,"cny":754.702,"dkk":677.221,"eur":90.7575,"gbp":76.6108,"hkd":955.6959,"idr":1401976.6268,"inr":7601.0098,"jpy":11938.215,"krw":132238.2292,"ltc":59.84440454817475,"mmk":124564.42058759999,"mxn":1619.1347,"myr":393.0957,"nzd":148.4503,"php":5315.0149,"pln":381.3587,"rub":3973.0086,"sek":791.16,"sgd":153.7814,"twd":3623.6843,"uah":1051.00353918,"usd":128.38,"vef":807.6801751200001,"xag":5.5156,"xau":0.0932,"xdr":80.1381,"zar":1233.5253},"market_cap":{"aud":1544578916.545,"bdt":117609550368.68365,"bhd":571394937.205442,"bmd":1515724889.0,"brl":3214398173.525,"btc":11806550.0,"cad":1500332689.765,"chf":1314973396.73,"cny":8910426898.1,"dkk":7995643597.55,"eur":1071532961.6249999,"gbp":904509240.74,"hkd":11283471428.145,"idr":16552507143145.54,"inr":89741702254.19,"jpy":140949132308.25,"krw":1561277264961.26,"ltc":706555954.5182526,"mmk":1470676059888.5288,"mxn":19116394792.285,"myr":4641104036.835,"nzd":1752685889.465,"php":62751989167.595,"pln":4502530559.485,"rub":46907524686.33,"sek":9340870098.0,"sgd":1815627788.17,"twd":42783209872.165,"uah":12408725835.50563,"usd":1515724889.0,"vef":9535916371.563036,"xag":65120207.18,"xau":1100370.4600000002,"xdr":946154484.5549998,"zar":14563678130.715},"total_volume":{"aud":0.0,"bdt":0.0,"bhd":0.0,"bmd":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"ltc":0.0,"mmk":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"uah":0.0,"usd":0.0,"vef":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":635.4009,"bdt":46187.0412867627,"bhd":224.2182020654,"bmd":594.6833,"brl":1330.3026,"btc":1.0,"cad":645.6162,"chf":537.5144,"cny":3818.09,"dkk":3291.0056,"eur":439.2353,"gbp":350.5075,"hkd":4609.773,"idr":7056654.8237,"inr":35623.7845,"jpy":60664.7779,"krw":605273.662,"ltc":58.68544600938967,"mmk":576316.9820261401,"mxn":7774.4393,"myr":1921.5065,"nzd":689.3958,"php":26182.8705,"pln":1816.6144,"rub":20449.5057,"sek":3957.6568,"sgd":743.7241,"twd":17936.3326,"uah":6989.384186896,"usd":594.6833,"vef":3749.9319498579002,"vnd":12616057.538675,"xag":30.3811,"xau":0.4678,"xdr":388.0442,"zar":6383.9883},"market_cap":{"aud":8191238932.305,"bdt":595417933396.2369,"bhd":2890497741.0160007,"bmd":7666330027.785,"brl":17149529452.77,"btc":12891450.0,"cad":8322928961.49,"chf":6929340011.88,"cny":49220716330.5,"dkk":42425834142.12,"eur":5662379908.185,"gbp":4518549910.875,"hkd":59426658140.85,"idr":90970512826987.36,"inr":459242236692.525,"jpy":782056951058.955,"krw":7802855149989.9,"ltc":756540492.9577465,"mmk":7429561557940.883,"mxn":100223795513.985,"myr":24771004969.425,"nzd":8887311485.91,"php":337535165907.225,"pln":23418793706.88,"rub":263623780256.265,"sek":51019934754.36,"sgd":9587682048.945,"twd":231225334896.27,"uah":90103296776.16043,"usd":7666330027.785,"vef":48342060234.99562,"vnd":162639274956951.8,"xag":391656431.595,"xau":6030620.31,"xdr":5002452402.09,"zar":82298865970.035},"total_volume":{"aud":40549103.56573137,"bdt":2947498375.4849124,"bhd":14308835.723827252,"bmd":37950646.151919045,"brl":84895343.87055749,"btc":63816.5661486022,"cad":41201008.933909185,"chf":34302323.26342622,"cny":243657393.04631656,"dkk":210020676.56782028,"eur":28030488.577251133,"gbp":22368185.059331186,"hkd":294179883.5845404,"idr":450331479344.50385,"inr":2273387600.0077996,"jpy":3871417811.7456107,"krw":38626486689.029686,"ltc":3745103.647218439,"mmk":36778570806.03395,"mxn":496138019.85674256,"myr":122623946.66221909,"nzd":43994872.673268534,"php":1670900887.223535,"pln":115930093.0241033,"rub":1305017233.2102678,"sek":252564066.9706653,"sgd":47461918.22395964,"twd":1144635155.8312302,"uah":446038498.30104274,"usd":37950646.151919045,"vef":239307780.33086348,"vnd":805113470451.4246,"xag":1938817.4778172984,"xau":29853.38964431611,"xdr":24763648.357881423,"zar":407404211.6388525}},"community_data":{"facebook_likes":22450,"twitter_followers":54747,"reddit_average_posts_48h":2.449,"reddit_average_comments_48h":266.163,"reddit_subscribers":122886,"reddit_accounts_active_48h":"957.0"},"developer_data":{"forks":3894,"stars":5469,"subscribers":757,"total_issues":4332,"closed_issues":3943,"pull_requests_merged":1950,"pull_request_contributors":201,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":130.7952,"brl":268.8555,"btc":1.0,"cad":136.8008,"chf":126.7471,"cny":830.3415,"dkk":769.4261,"eur":103.1862,"gbp":86.889,"hkd":1043.747,"idr":1306348.9692,"inr":7304.2353,"jpy":13203.1967,"krw":149390.4586,"mxn":1633.6086,"myr":407.992,"nzd":158.5211,"php":5543.837,"pln":429.2283,"rub":4203.4233,"sek":884.1254,"sgd":166.2931,"usd":135.3,"xag":5.716,"xau":0.0938,"zar":1223.2239},"market_cap":{"aud":1450558006.5599997,"brl":2981688151.6499996,"btc":11090299.999999998,"cad":1517161912.2399998,"chf":1405663363.1299999,"cny":9208736337.449999,"dkk":8533166276.829999,"eur":1144365913.86,"gbp":963625076.6999998,"hkd":11575467354.099998,"idr":14487801973118.756,"inr":81006160747.59,"jpy":146427412362.00998,"krw":1656785003011.5798,"mxn":18117209456.579998,"myr":4524753677.599999,"nzd":1758046555.3299997,"php":61482815481.09999,"pln":4760270615.489999,"rub":46617225423.99,"sek":9805215923.619999,"sgd":1844240366.9299998,"usd":1500517590,"xag":63392154.79999999,"xau":1040270.1399999998,"zar":13565920018.169998},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"usd":0,"xag":0.0,"xau":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

View File

@ -0,0 +1 @@
{"id":"bitcoin","symbol":"btc","name":"Bitcoin","localization":{"en":"Bitcoin","de":"Bitcoin","es":"Bitcoin","fr":"Bitcoin","it":"Bitcoin","pl":"Bitcoin","ro":"Bitcoin","hu":"Bitcoin","nl":"Bitcoin","pt":"Bitcoin","sv":"Bitcoin","vi":"Bitcoin","tr":"Bitcoin","ru":"биткоина","ja":"ビットコイン","zh":"比特币","zh-tw":"比特幣","ko":"비트코인","ar":"بيتكوين","th":"บิตคอยน์","id":"Bitcoin"},"image":{"thumb":"https://assets.coingecko.com/coins/images/1/thumb/bitcoin.png?1547033579","small":"https://assets.coingecko.com/coins/images/1/small/bitcoin.png?1547033579"},"market_data":{"current_price":{"aud":140.0534,"brl":287.7259,"btc":1.0,"cad":146.3907,"chf":135.5619,"cny":889.0842,"dkk":822.6608,"eur":110.3745,"gbp":93.0697,"hkd":1117.9148,"idr":1394387.3129,"inr":7822.3338,"jpy":14108.4087,"krw":159839.6429,"mxn":1748.706,"myr":436.5932,"nzd":169.7084,"php":5937.7695,"pln":459.2644,"rub":4501.5503,"sek":944.2334,"sgd":178.0707,"twd":4262.3287,"usd":141.96,"xag":6.1223,"xau":0.1005,"xdr":95.6015,"zar":1309.9167},"market_cap":{"aud":1553878467.66,"brl":3192290087.91,"btc":11094900.0,"cad":1624190177.43,"chf":1504045724.31,"cny":9864300290.58,"dkk":9127339309.92,"eur":1224594040.05,"gbp":1032599014.53,"hkd":12403152914.52,"idr":15470587797894.21,"inr":86788011277.62,"jpy":156531383685.63,"krw":1773404854011.21,"mxn":19401718199.4,"myr":4843957894.68,"nzd":1882897727.16,"php":65878958825.55,"pln":5095492591.56,"rub":49944250423.47,"sek":10476175149.66,"sgd":1975676609.43,"twd":47290110693.63,"usd":1575032004.0,"xag":67926306.27,"xau":1115037.45,"xdr":1060689082.35,"zar":14533394794.83},"total_volume":{"aud":0.0,"brl":0.0,"btc":0.0,"cad":0.0,"chf":0.0,"cny":0.0,"dkk":0.0,"eur":0.0,"gbp":0.0,"hkd":0.0,"idr":0.0,"inr":0.0,"jpy":0.0,"krw":0.0,"mxn":0.0,"myr":0.0,"nzd":0.0,"php":0.0,"pln":0.0,"rub":0.0,"sek":0.0,"sgd":0.0,"twd":0.0,"usd":0.0,"xag":0.0,"xau":0.0,"xdr":0.0,"zar":0.0}},"community_data":{"facebook_likes":null,"twitter_followers":null,"reddit_average_posts_48h":0.0,"reddit_average_comments_48h":0.0,"reddit_subscribers":null,"reddit_accounts_active_48h":null},"developer_data":{"forks":null,"stars":null,"subscribers":null,"total_issues":null,"closed_issues":null,"pull_requests_merged":null,"pull_request_contributors":null,"code_additions_deletions_4_weeks":{"additions":null,"deletions":null},"commit_count_4_weeks":null},"public_interest_stats":{"alexa_rank":null,"bing_matches":null}}

File diff suppressed because one or more lines are too long

View File

@ -186,6 +186,8 @@ func (s *PublicServer) ConnectFullPublicInterface() {
serveMux.HandleFunc(path+"api/v2/sendtx/", s.jsonHandler(s.apiSendTx, apiV2))
serveMux.HandleFunc(path+"api/v2/estimatefee/", s.jsonHandler(s.apiEstimateFee, apiV2))
serveMux.HandleFunc(path+"api/v2/feestats/", s.jsonHandler(s.apiFeeStats, apiV2))
serveMux.HandleFunc(path+"api/v2/tickers/", s.jsonHandler(s.apiTickers, apiV2))
serveMux.HandleFunc(path+"api/v2/tickers-list/", s.jsonHandler(s.apiTickersList, apiV2))
// socket.io interface
serveMux.Handle(path+"socket.io/", s.socketio.GetHandler())
// websocket interface
@ -210,6 +212,11 @@ func (s *PublicServer) OnNewBlock(hash string, height uint32) {
s.websocket.OnNewBlock(hash, height)
}
// OnNewFiatRatesTicker notifies users subscribed to bitcoind/fiatrates about new ticker
func (s *PublicServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) {
s.websocket.OnNewFiatRatesTicker(ticker)
}
// OnNewTxAddr notifies users subscribed to bitcoind/addresstxid about new block
func (s *PublicServer) OnNewTxAddr(tx *bchain.Tx, desc bchain.AddressDescriptor) {
s.socketio.OnNewTxAddr(tx.Txid, desc)
@ -1088,6 +1095,43 @@ func (s *PublicServer) apiSendTx(r *http.Request, apiVersion int) (interface{},
return nil, api.NewAPIError("Missing tx blob", true)
}
// apiTickersList returns a list of available FiatRates currencies
func (s *PublicServer) apiTickersList(r *http.Request, apiVersion int) (interface{}, error) {
s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-list"}).Inc()
date := strings.ToLower(r.URL.Query().Get("date"))
result, err := s.api.GetFiatRatesTickersList(date)
return result, err
}
// apiTickers returns FiatRates ticker prices for the specified block or date.
func (s *PublicServer) apiTickers(r *http.Request, apiVersion int) (interface{}, error) {
var result *db.ResultTickerAsString
var err error
currency := strings.ToLower(r.URL.Query().Get("currency"))
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, currency)
} else if date := r.URL.Query().Get("date"); date != "" {
// Get tickers for specified date
s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-date"}).Inc()
resultTickers, err := s.api.GetFiatRatesForDates([]string{date}, currency)
if err != nil {
return nil, err
}
result = &resultTickers.Tickers[0]
} else {
// No parameters - get the latest available ticker
s.metrics.ExplorerViews.With(common.Labels{"action": "api-tickers-last"}).Inc()
result, err = s.api.GetCurrentFiatRates(currency)
}
if err != nil {
return nil, err
}
return result, nil
}
type resultEstimateFeeAsString struct {
Result string `json:"result"`
}

View File

@ -58,6 +58,9 @@ func setupRocksDB(t *testing.T, parser bchain.BlockChainParser) (*db.RocksDB, *c
if err := d.ConnectBlock(block2); err != nil {
t.Fatal(err)
}
if err := InitTestFiatRates(d); err != nil {
t.Fatal(err)
}
is.FinishedSync(block2.Height)
return d, is, tmp
}
@ -146,6 +149,41 @@ func newPostRequest(u string, body string) *http.Request {
return r
}
// InitTestFiatRates initializes test data for /api/v2/tickers endpoint
func InitTestFiatRates(d *db.RocksDB) error {
convertedDate, err := db.FiatRatesConvertDate("20191121140000")
if err != nil {
return err
}
ticker := &db.CurrencyRatesTicker{
Timestamp: convertedDate,
Rates: map[string]json.Number{
"usd": "7814.5",
"eur": "7100.0",
},
}
err = d.FiatRatesStoreTicker(ticker)
if err != nil {
return err
}
convertedDate, err = db.FiatRatesConvertDate("20191121143015")
if err != nil {
return err
}
ticker = &db.CurrencyRatesTicker{
Timestamp: convertedDate,
Rates: map[string]json.Number{
"usd": "7914.5",
"eur": "7134.1",
},
}
err = d.FiatRatesStoreTicker(ticker)
if err != nil {
return err
}
return nil
}
func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) {
tests := []struct {
name string
@ -460,6 +498,105 @@ func httpTestsBitcoinType(t *testing.T, ts *httptest.Server) {
`{"txCount":3,"totalFeesSat":"1284","averageFeePerKb":1398,"decilesFeePerKb":[155,155,155,155,1679,1679,1679,2361,2361,2361,2361]}`,
},
},
{
name: "apiFiatRates missing currency",
r: newGetRequest(ts.URL + "/api/v2/tickers"),
status: http.StatusBadRequest,
contentType: "application/json; charset=utf-8",
body: []string{
`{"error":"Missing or empty \"currency\" parameter"}`,
},
},
{
name: "apiFiatRates get last rate",
r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121143015","rates":{"usd":7914.5}}`,
},
},
{
name: "apiFiatRates get rate by exact date",
r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd&date=20191121140000"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","rates":{"usd":7814.5}}`,
},
},
{
name: "apiFiatRates incorrect date",
r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd&date=yesterday"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"error":"Date \"yesterday\" does not match any of available formats. Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD"}`,
},
},
{
name: "apiFiatRates future date",
r: newGetRequest(ts.URL + "/api/v2/tickers?currency=usd&date=20200101000000"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"error":"No tickers available for 2020-01-01 00:00:00 +0000 UTC (usd)"}`,
},
},
{
name: "apiFiatRates get EUR rate (exact date)",
r: newGetRequest(ts.URL + "/api/v2/tickers?date=20191121140000&currency=eur"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","rates":{"eur":7100.0,"usd":7814.5}}`,
},
},
{
name: "apiFiatRates get closest rate",
r: newGetRequest(ts.URL + "/api/v2/tickers?date=20191121130000&currency=usd"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","rates":{"usd":7814.5}}`,
},
},
{
name: "apiFiatRates get rate by block height",
r: newGetRequest(ts.URL + "/api/v2/tickers?block=225494&currency=usd"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","rates":{"usd":7814.5}}`,
},
},
{
name: "apiFiatRates get rate for EUR",
r: newGetRequest(ts.URL + "/api/v2/tickers?date=20191121140000&currency=eur"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","rates":{"eur":7100.0,"usd":7814.5}}`,
},
},
{
name: "apiFiatRates get exact rate for an incorrect currency",
r: newGetRequest(ts.URL + "/api/v2/tickers?date=20191121140000&currency=does_not_exist"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"error":"Currency \"does_not_exist\" is not available for timestamp 20191121140000. Available currencies are: eur, usd."}`,
},
},
{
name: "apiTickerList",
r: newGetRequest(ts.URL + "/api/v2/tickers-list?date=20191121140000"),
status: http.StatusOK,
contentType: "application/json; charset=utf-8",
body: []string{
`{"data_timestamp":"20191121140000","available_currencies":["eur","usd"]}`,
},
},
{
name: "apiAddress v1",
r: newGetRequest(ts.URL + "/api/v1/address/mv9uLThosiEnGRbVPS7Vhyw6VssbVRsiAw"),
@ -965,6 +1102,154 @@ func websocketTestsBitcoinType(t *testing.T, ts *httptest.Server) {
},
want: `{"id":"16","data":{}}`,
},
{
name: "websocket getCurrentFiatRates no currency",
req: websocketReq{
Method: "getCurrentFiatRates",
Params: map[string]interface{}{
"": "",
},
},
want: `{"id":"17","data":{"error":{"message":"Missing or empty \"currency\" parameter"}}}`,
},
{
name: "websocket getCurrentFiatRates usd",
req: websocketReq{
Method: "getCurrentFiatRates",
Params: map[string]interface{}{
"currency": "usd",
},
},
want: `{"id":"18","data":{"data_timestamp":"20191121143015","rates":{"usd":7914.5}}}`,
},
{
name: "websocket getCurrentFiatRates eur",
req: websocketReq{
Method: "getCurrentFiatRates",
Params: map[string]interface{}{
"currency": "eur",
},
},
want: `{"id":"19","data":{"data_timestamp":"20191121143015","rates":{"eur":7134.1,"usd":7914.5}}}`,
},
{
name: "websocket getCurrentFiatRates incorrect currency",
req: websocketReq{
Method: "getCurrentFiatRates",
Params: map[string]interface{}{
"currency": "does-not-exist",
},
},
want: `{"id":"20","data":{"error":{"message":"Currency \"does-not-exist\" is not available for timestamp 20191121143015. Available currencies are: eur, usd."}}}`,
},
{
name: "websocket getFiatRatesForDates missing date",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
},
},
want: `{"id":"21","data":{"error":{"message":"No dates provided"}}}`,
},
{
name: "websocket getFiatRatesForDates incorrect date",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"yesterday"},
},
},
want: `{"id":"22","data":{"tickers":[{"error":"Date \"yesterday\" does not match any of available formats. Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD"}]}}`,
},
{
name: "websocket getFiatRatesForDates incorrect (future) date",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"20200101000000"},
},
},
want: `{"id":"23","data":{"tickers":[{"error":"No tickers available for 2020-01-01 00:00:00 +0000 UTC (usd)"}]}}`,
},
{
name: "websocket getFiatRatesForDates exact date",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"20191121140000"},
},
},
want: `{"id":"24","data":{"tickers":[{"data_timestamp":"20191121140000","rates":{"usd":7814.5}}]}}`,
},
{
name: "websocket getFiatRatesForDates closest date, eur",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "eur",
"dates": []string{"20191121130000"},
},
},
want: `{"id":"25","data":{"tickers":[{"data_timestamp":"20191121140000","rates":{"eur":7100.0,"usd":7814.5}}]}}`,
},
{
name: "websocket getFiatRatesForDates multiple dates usd",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"20191121140000", "20191121143015"},
},
},
want: `{"id":"26","data":{"tickers":[{"data_timestamp":"20191121140000","rates":{"usd":7814.5}},{"data_timestamp":"20191121143015","rates":{"usd":7914.5}}]}}`,
},
{
name: "websocket getFiatRatesForDates multiple dates eur",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "eur",
"dates": []string{"20191121140000", "20191121143015"},
},
},
want: `{"id":"27","data":{"tickers":[{"data_timestamp":"20191121140000","rates":{"eur":7100.0,"usd":7814.5}},{"data_timestamp":"20191121143015","rates":{"eur":7134.1,"usd":7914.5}}]}}`,
},
{
name: "websocket getFiatRatesForDates multiple dates with an error",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"20191121140000", "20191121143015", "not-a-real-date"},
},
},
want: `{"id":"28","data":{"tickers":[{"data_timestamp":"20191121140000","rates":{"usd":7814.5}},{"data_timestamp":"20191121143015","rates":{"usd":7914.5}},{"error":"Date \"not-a-real-date\" does not match any of available formats. Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD"}]}}`,
},
{
name: "websocket getFiatRatesForDates multiple errors",
req: websocketReq{
Method: "getFiatRatesForDates",
Params: map[string]interface{}{
"currency": "usd",
"dates": []string{"20200101000000", "not-a-real-date"},
},
},
want: `{"id":"29","data":{"tickers":[{"error":"No tickers available for 2020-01-01 00:00:00 +0000 UTC (usd)"},{"error":"Date \"not-a-real-date\" does not match any of available formats. Possible formats are: YYYYMMDDhhmmss, YYYYMMDDhhmm, YYYYMMDDhh, YYYYMMDD"}]}}`,
},
{
name: "websocket getTickersList",
req: websocketReq{
Method: "getFiatRatesTickersList",
Params: map[string]interface{}{
"date": "20191121140000",
},
},
want: `{"id":"30","data":{"data_timestamp":"20191121140000","available_currencies":["eur","usd"]}}`,
},
}
// send all requests at once

View File

@ -53,21 +53,23 @@ type websocketChannel struct {
// WebsocketServer is a handle to websocket server
type WebsocketServer struct {
socket *websocket.Conn
upgrader *websocket.Upgrader
db *db.RocksDB
txCache *db.TxCache
chain bchain.BlockChain
chainParser bchain.BlockChainParser
mempool bchain.Mempool
metrics *common.Metrics
is *common.InternalState
api *api.Worker
block0hash string
newBlockSubscriptions map[*websocketChannel]string
newBlockSubscriptionsLock sync.Mutex
addressSubscriptions map[string]map[*websocketChannel]string
addressSubscriptionsLock sync.Mutex
socket *websocket.Conn
upgrader *websocket.Upgrader
db *db.RocksDB
txCache *db.TxCache
chain bchain.BlockChain
chainParser bchain.BlockChainParser
mempool bchain.Mempool
metrics *common.Metrics
is *common.InternalState
api *api.Worker
block0hash string
newBlockSubscriptions map[*websocketChannel]string
newBlockSubscriptionsLock sync.Mutex
addressSubscriptions map[string]map[*websocketChannel]string
addressSubscriptionsLock sync.Mutex
fiatRatesSubscriptions map[string]map[*websocketChannel]string
fiatRatesSubscriptionsLock sync.Mutex
}
// NewWebsocketServer creates new websocket interface to blockbook and returns its handle
@ -86,17 +88,18 @@ func NewWebsocketServer(db *db.RocksDB, chain bchain.BlockChain, mempool bchain.
WriteBufferSize: 1024 * 32,
CheckOrigin: checkOrigin,
},
db: db,
txCache: txCache,
chain: chain,
chainParser: chain.GetChainParser(),
mempool: mempool,
metrics: metrics,
is: is,
api: api,
block0hash: b0,
newBlockSubscriptions: make(map[*websocketChannel]string),
addressSubscriptions: make(map[string]map[*websocketChannel]string),
db: db,
txCache: txCache,
chain: chain,
chainParser: chain.GetChainParser(),
mempool: mempool,
metrics: metrics,
is: is,
api: api,
block0hash: b0,
newBlockSubscriptions: make(map[*websocketChannel]string),
addressSubscriptions: make(map[string]map[*websocketChannel]string),
fiatRatesSubscriptions: make(map[string]map[*websocketChannel]string),
}
return s, nil
}
@ -214,6 +217,7 @@ func (s *WebsocketServer) onConnect(c *websocketChannel) {
func (s *WebsocketServer) onDisconnect(c *websocketChannel) {
s.unsubscribeNewBlock(c)
s.unsubscribeAddresses(c)
s.unsubscribeFiatRates(c)
glog.Info("Client disconnected ", c.id, ", ", c.ip)
s.metrics.WebsocketClients.Dec()
}
@ -298,10 +302,54 @@ var requestHandlers = map[string]func(*WebsocketServer, *websocketChannel, *webs
"unsubscribeAddresses": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
return s.unsubscribeAddresses(c)
},
"subscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Currency string `json:"currency"`
}{}
err = json.Unmarshal(req.Params, &r)
if err != nil {
return nil, err
}
return s.subscribeFiatRates(c, r.Currency, req)
},
"unsubscribeFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
return s.unsubscribeFiatRates(c)
},
"ping": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct{}{}
return r, nil
},
"getCurrentFiatRates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Currency string `json:"currency"`
}{}
err = json.Unmarshal(req.Params, &r)
if err == nil {
rv, err = s.getCurrentFiatRates(r.Currency)
}
return
},
"getFiatRatesForDates": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Dates []string `json:"dates"`
Currency string `json:"currency"`
}{}
err = json.Unmarshal(req.Params, &r)
if err == nil {
rv, err = s.getFiatRatesForDates(r.Dates, r.Currency)
}
return
},
"getFiatRatesTickersList": func(s *WebsocketServer, c *websocketChannel, req *websocketReq) (rv interface{}, err error) {
r := struct {
Date string `json:"date"`
}{}
err = json.Unmarshal(req.Params, &r)
if err == nil {
rv, err = s.getFiatRatesTickersList(r.Date)
}
return
},
}
func sendResponse(c *websocketChannel, req *websocketReq, data interface{}) {
@ -613,6 +661,36 @@ func (s *WebsocketServer) unsubscribeAddresses(c *websocketChannel) (res interfa
return &subscriptionResponse{false}, nil
}
// subscribeFiatRates subscribes all FiatRates subscriptions by this channel
func (s *WebsocketServer) subscribeFiatRates(c *websocketChannel, currency string, req *websocketReq) (res interface{}, err error) {
// unsubscribe all previous subscriptions
s.unsubscribeFiatRates(c)
s.fiatRatesSubscriptionsLock.Lock()
defer s.fiatRatesSubscriptionsLock.Unlock()
as, ok := s.fiatRatesSubscriptions[currency]
if !ok {
as = make(map[*websocketChannel]string)
s.fiatRatesSubscriptions[currency] = as
}
as[c] = req.ID
return &subscriptionResponse{true}, nil
}
// unsubscribeFiatRates unsubscribes all FiatRates subscriptions by this channel
func (s *WebsocketServer) unsubscribeFiatRates(c *websocketChannel) (res interface{}, err error) {
s.fiatRatesSubscriptionsLock.Lock()
defer s.fiatRatesSubscriptionsLock.Unlock()
for _, sa := range s.fiatRatesSubscriptions {
for sc := range sa {
if sc == c {
delete(sa, c)
}
}
}
return &subscriptionResponse{false}, nil
}
// OnNewBlock is a callback that broadcasts info about new block to subscribed clients
func (s *WebsocketServer) OnNewBlock(hash string, height uint32) {
s.newBlockSubscriptionsLock.Lock()
@ -640,8 +718,9 @@ func (s *WebsocketServer) OnNewTxAddr(tx *bchain.Tx, addrDesc bchain.AddressDesc
// check if there is any subscription but release the lock immediately, GetTransactionFromBchainTx may take some time
s.addressSubscriptionsLock.Lock()
as, ok := s.addressSubscriptions[string(addrDesc)]
lenAs := len(as)
s.addressSubscriptionsLock.Unlock()
if ok && len(as) > 0 {
if ok && lenAs > 0 {
addr, _, err := s.chainParser.GetAddressesFromAddrDesc(addrDesc)
if err != nil {
glog.Error("GetAddressesFromAddrDesc error ", err, " for ", addrDesc)
@ -678,3 +757,51 @@ func (s *WebsocketServer) OnNewTxAddr(tx *bchain.Tx, addrDesc bchain.AddressDesc
}
}
}
func (s *WebsocketServer) broadcastTicker(coin string, rate json.Number) {
s.fiatRatesSubscriptionsLock.Lock()
defer s.fiatRatesSubscriptionsLock.Unlock()
as, ok := s.fiatRatesSubscriptions[coin]
if ok && len(as) > 0 {
data := struct {
Rate interface{} `json:"rate"`
}{
Rate: rate,
}
// get the list of subscriptions again, this time keep the lock
as, ok = s.fiatRatesSubscriptions[coin]
if ok {
for c, id := range as {
if c.IsAlive() {
c.out <- &websocketRes{
ID: id,
Data: &data,
}
}
}
glog.Info("broadcasting new rate ", rate, " for coin ", coin, " to ", len(as), " channels")
}
}
}
// OnNewFiatRatesTicker is a callback that broadcasts info about fiat rates affecting subscribed currency
func (s *WebsocketServer) OnNewFiatRatesTicker(ticker *db.CurrencyRatesTicker) {
for currency, rate := range ticker.Rates {
s.broadcastTicker(currency, rate)
}
}
func (s *WebsocketServer) getCurrentFiatRates(currency string) (interface{}, error) {
ret, err := s.api.GetCurrentFiatRates(currency)
return ret, err
}
func (s *WebsocketServer) getFiatRatesForDates(dates []string, currency string) (interface{}, error) {
ret, err := s.api.GetFiatRatesForDates(dates, currency)
return ret, err
}
func (s *WebsocketServer) getFiatRatesTickersList(date string) (interface{}, error) {
ret, err := s.api.GetFiatRatesTickersList(date)
return ret, err
}

View File

@ -1,6 +1,5 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
@ -279,6 +278,70 @@
});
}
function getFiatRatesForDates() {
const method = 'getFiatRatesForDates';
var dates = document.getElementById('getFiatRatesForDatesList').value.split(",");
var currency = document.getElementById('getFiatRatesForDatesCurrency').value;
dates = dates.map(s => s.trim());
const params = {
dates,
"currency": currency
};
send(method, params, function (result) {
document.getElementById('getFiatRatesForDatesResult').innerText = JSON.stringify(result).replace(/,/g, ", ");
});
}
function getCurrentFiatRates() {
const method = 'getCurrentFiatRates';
var currency = document.getElementById('getCurrentFiatRatesCurrency').value;
const params = {
"currency": currency
};
send(method, params, function (result) {
document.getElementById('getCurrentFiatRatesResult').innerText = JSON.stringify(result).replace(/,/g, ", ");
});
}
function getFiatRatesTickersList() {
const method = 'getFiatRatesTickersList';
var date = document.getElementById('getFiatRatesTickersListDate').value;
const params = {
date,
};
send(method, params, function (result) {
document.getElementById('getFiatRatesTickersListResult').innerText = JSON.stringify(result).replace(/,/g, ", ");
});
}
function subscribeNewFiatRatesTicker() {
const method = 'subscribeFiatRates';
var currency = document.getElementById('subscribeFiatRatesCurrency').value;
const params = {
"currency": currency
};
if (subscribeNewFiatRatesTickerId) {
delete subscriptions[subscribeNewFiatRatesTickerId];
subscribeNewFiatRatesTickerId = "";
}
subscribeNewFiatRatesTickerId = subscribe(method, params, function (result) {
document.getElementById('subscribeNewFiatRatesTickerResult').innerText += JSON.stringify(result).replace(/,/g, ", ") + "\n";
});
document.getElementById('subscribeNewFiatRatesTickerId').innerText = subscribeNewFiatRatesTickerId;
document.getElementById('unsubscribeNewFiatRatesTickerButton').setAttribute("style", "display: inherit;");
}
function unsubscribeNewFiatRatesTicker() {
const method = 'unsubscribeFiatRates';
const params = {
};
unsubscribe(method, subscribeNewFiatRatesTickerId, params, function (result) {
subscribeNewFiatRatesTickerId = "";
document.getElementById('subscribeNewFiatRatesTickerResult').innerText += JSON.stringify(result).replace(/,/g, ", ") + "\n";
document.getElementById('subscribeNewFiatRatesTickerId').innerText = "";
document.getElementById('unsubscribeNewFiatRatesTickerButton').setAttribute("style", "display: none;");
});
}
</script>
</head>
@ -460,6 +523,59 @@
<div class="row">
<div class="col" id="subscribeAddressesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get fiat rates for dates" onclick="getFiatRatesForDates()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesForDatesCurrency" value="usd">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesForDatesList" value="20191121140000,20191121143015">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesForDatesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get current firat rates" onclick="getCurrentFiatRates()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getCurrentFiatRatesCurrency" value="usd">
</div>
</div>
<div class="row">
<div class="col" id="getCurrentFiatRatesResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="get fiat rates tickers" onclick="getFiatRatesTickersList()">
</div>
<div class="col-8">
<input type="text" class="form-control" id="getFiatRatesTickersListDate" value="20191121140000">
</div>
</div>
<div class="row">
<div class="col" id="getFiatRatesTickersListResult"></div>
</div>
<div class="row">
<div class="col">
<input class="btn btn-secondary" type="button" value="subscribe new fiat rates" onclick="subscribeNewFiatRatesTicker()">
</div>
<div class="col-4">
<span id="subscribeNewFiatRatesTickerId"></span>
</div>
<div class="col-8">
<input type="text" class="form-control" id="subscribeFiatRatesCurrency" value="usd">
</div>
<div class="col">
<input class="btn btn-secondary" id="unsubscribeNewFiatRatesTickerButton" style="display: none;" type="button" value="unsubscribe" onclick="unsubscribeNewFiatRatesTicker()">
</div>
</div>
<div class="row">
<div class="col" id="subscribeNewFiatRatesTickerResult"></div>
</div>
</div>
</body>
<script>