decred.org/dcrdex@v1.0.5/client/core/exchangeratefetcher.go (about)

     1  // This code is available on the terms of the project LICENSE.md file,
     2  // also available online at https://blueoakcouncil.org/license/1.0.0.
     3  
     4  package core
     5  
     6  import (
     7  	"context"
     8  	"fmt"
     9  	"sync"
    10  	"time"
    11  
    12  	"decred.org/dcrdex/dex"
    13  	"decred.org/dcrdex/dex/dexnet"
    14  	"decred.org/dcrdex/dex/fiatrates"
    15  )
    16  
    17  const (
    18  	// DefaultFiatCurrency is the currency for displaying assets fiat value.
    19  	DefaultFiatCurrency = "USD"
    20  	// fiatRateRequestInterval is the amount of time between calls to the exchange API.
    21  	fiatRateRequestInterval = 12 * time.Minute
    22  	// fiatRateDataExpiry : Any data older than fiatRateDataExpiry will be discarded.
    23  	fiatRateDataExpiry = 60 * time.Minute
    24  	fiatRequestTimeout = time.Second * 5
    25  
    26  	// Tokens. Used to identify fiat rate source, source name must not contain a
    27  	// comma.
    28  	messari       = "Messari"
    29  	coinpaprika   = "Coinpaprika"
    30  	dcrdataDotOrg = "dcrdata"
    31  )
    32  
    33  var (
    34  	dcrDataURL = "https://explorer.dcrdata.org/api/exchangerate"
    35  	// The best info I can find on Messari says
    36  	//    Without an API key requests are rate limited to 20 requests per minute
    37  	//    and 1000 requests per day.
    38  	// For a
    39  	// fiatRateRequestInterval of 12 minutes, to hit 20 requests per minute, we
    40  	// would need to have 20 * 12 = 480 assets. To hit 1000 requests per day,
    41  	// we would need 12 * 60 / (86,400 / 1000) = 8.33 assets. Very likely. So
    42  	// we're in a similar position to coinpaprika here too.
    43  	messariURL  = "https://data.messari.io/api/v1/assets/%s/metrics/market-data"
    44  	btcBipID, _ = dex.BipSymbolID("btc")
    45  	dcrBipID, _ = dex.BipSymbolID("dcr")
    46  )
    47  
    48  // fiatRateFetchers is the list of all supported fiat rate fetchers.
    49  var fiatRateFetchers = map[string]rateFetcher{
    50  	coinpaprika:   FetchCoinpaprikaRates,
    51  	dcrdataDotOrg: FetchDcrdataRates,
    52  	messari:       FetchMessariRates,
    53  }
    54  
    55  // fiatRateInfo holds the fiat rate and the last update time for an
    56  // asset.
    57  type fiatRateInfo struct {
    58  	rate       float64
    59  	lastUpdate time.Time
    60  }
    61  
    62  // rateFetcher can fetch fiat rates for assets from an API.
    63  type rateFetcher func(context context.Context, logger dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64
    64  
    65  type commonRateSource struct {
    66  	fetchRates rateFetcher
    67  
    68  	mtx       sync.RWMutex
    69  	fiatRates map[uint32]*fiatRateInfo
    70  }
    71  
    72  // isExpired checks the last update time for all fiat rates against the
    73  // provided expiryTime. This only returns true if all rates are expired.
    74  func (source *commonRateSource) isExpired(expiryTime time.Duration) bool {
    75  	now := time.Now()
    76  
    77  	source.mtx.RLock()
    78  	defer source.mtx.RUnlock()
    79  	if len(source.fiatRates) == 0 {
    80  		return false
    81  	}
    82  	for _, rateInfo := range source.fiatRates {
    83  		if now.Sub(rateInfo.lastUpdate) < expiryTime {
    84  			return false // one not expired is enough
    85  		}
    86  	}
    87  	return true
    88  }
    89  
    90  // assetRate returns the fiat rate information for the assetID specified. The
    91  // fiatRateInfo returned should not be modified by the caller.
    92  func (source *commonRateSource) assetRate(assetID uint32) *fiatRateInfo {
    93  	source.mtx.RLock()
    94  	defer source.mtx.RUnlock()
    95  	return source.fiatRates[assetID]
    96  }
    97  
    98  // refreshRates updates the last update time and the rate information for assets.
    99  func (source *commonRateSource) refreshRates(ctx context.Context, logger dex.Logger, assets map[uint32]*SupportedAsset) {
   100  	fiatRates := source.fetchRates(ctx, logger, assets)
   101  	now := time.Now()
   102  	source.mtx.Lock()
   103  	defer source.mtx.Unlock()
   104  	for assetID, fiatRate := range fiatRates {
   105  		if fiatRate <= 0 {
   106  			continue
   107  		}
   108  		source.fiatRates[assetID] = &fiatRateInfo{
   109  			rate:       fiatRate,
   110  			lastUpdate: now,
   111  		}
   112  	}
   113  }
   114  
   115  // Used to initialize a fiat rate source.
   116  func newCommonRateSource(fetcher rateFetcher) *commonRateSource {
   117  	return &commonRateSource{
   118  		fetchRates: fetcher,
   119  		fiatRates:  make(map[uint32]*fiatRateInfo),
   120  	}
   121  }
   122  
   123  // FetchCoinpaprikaRates retrieves and parses fiat rate data from the
   124  // Coinpaprika API. See https://api.coinpaprika.com/#operation/getTickersById
   125  // for sample request and response information.
   126  func FetchCoinpaprikaRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 {
   127  	coinpapAssets := make([]*fiatrates.CoinpaprikaAsset, 0, len(assets) /* too small cuz tokens*/)
   128  	for assetID, a := range assets {
   129  		coinpapAssets = append(coinpapAssets, &fiatrates.CoinpaprikaAsset{
   130  			AssetID: assetID,
   131  			Name:    a.Name,
   132  			Symbol:  a.Symbol,
   133  		})
   134  	}
   135  	return fiatrates.FetchCoinpaprikaRates(ctx, coinpapAssets, log)
   136  }
   137  
   138  // FetchDcrdataRates retrieves and parses fiat rate data from dcrdata
   139  // exchange rate API.
   140  func FetchDcrdataRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 {
   141  	assetBTC := assets[btcBipID]
   142  	assetDCR := assets[dcrBipID]
   143  	noBTCAsset := assetBTC == nil || assetBTC.Wallet == nil
   144  	noDCRAsset := assetDCR == nil || assetDCR.Wallet == nil
   145  	if noBTCAsset && noDCRAsset {
   146  		return nil
   147  	}
   148  
   149  	fiatRates := make(map[uint32]float64)
   150  	res := new(struct {
   151  		DcrPrice float64 `json:"dcrPrice"`
   152  		BtcPrice float64 `json:"btcPrice"`
   153  	})
   154  
   155  	if err := getRates(ctx, dcrDataURL, res); err != nil {
   156  		log.Error(err)
   157  		return nil
   158  	}
   159  
   160  	if !noBTCAsset {
   161  		fiatRates[btcBipID] = res.BtcPrice
   162  	}
   163  	if !noDCRAsset {
   164  		fiatRates[dcrBipID] = res.DcrPrice
   165  	}
   166  
   167  	return fiatRates
   168  }
   169  
   170  // FetchMessariRates retrieves and parses fiat rate data from the Messari API.
   171  // See https://messari.io/api/docs#operation/Get%20Asset%20Market%20Data for
   172  // sample request and response information.
   173  func FetchMessariRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 {
   174  	fiatRates := make(map[uint32]float64)
   175  	fetchRate := func(sa *SupportedAsset) {
   176  		assetID := sa.ID
   177  		if sa.Wallet == nil {
   178  			// we don't want to fetch rate for assets with no wallet.
   179  			return
   180  		}
   181  
   182  		res := new(struct {
   183  			Data struct {
   184  				MarketData struct {
   185  					Price float64 `json:"price_usd"`
   186  				} `json:"market_data"`
   187  			} `json:"data"`
   188  		})
   189  
   190  		slug := dex.TokenSymbol(sa.Symbol)
   191  		reqStr := fmt.Sprintf(messariURL, slug)
   192  
   193  		ctx, cancel := context.WithTimeout(ctx, fiatRequestTimeout)
   194  		defer cancel()
   195  
   196  		if err := getRates(ctx, reqStr, res); err != nil {
   197  			log.Errorf("Error getting fiat exchange rates from messari: %v", err)
   198  			return
   199  		}
   200  
   201  		fiatRates[assetID] = res.Data.MarketData.Price
   202  	}
   203  
   204  	for _, sa := range assets {
   205  		fetchRate(sa)
   206  	}
   207  	return fiatRates
   208  }
   209  
   210  func getRates(ctx context.Context, uri string, thing any) error {
   211  	return dexnet.Get(ctx, uri, thing, dexnet.WithSizeLimit(1<<22))
   212  }