diff --git a/client/asset/doge/doge.go b/client/asset/doge/doge.go index 4c62805000..fef2a815f2 100644 --- a/client/asset/doge/doge.go +++ b/client/asset/doge/doge.go @@ -78,7 +78,7 @@ var ( } // WalletInfo defines some general information about a Dogecoin wallet. WalletInfo = &asset.WalletInfo{ - Name: "Doge", + Name: "Dogecoin", Version: version, UnitInfo: dexdoge.UnitInfo, AvailableWallets: []*asset.WalletDefinition{{ diff --git a/client/core/core.go b/client/core/core.go index d27bb28a5e..4a37db34b9 100644 --- a/client/core/core.go +++ b/client/core/core.go @@ -1135,6 +1135,12 @@ type Core struct { sentCommitsMtx sync.Mutex sentCommits map[order.Commitment]chan struct{} + + ratesMtx sync.RWMutex + fiatRateSources map[string]*commonRateSource + // stopFiatRateFetching will be used to shutdown fetchFiatExchangeRates + // goroutine when all rate sources have been disabled. + stopFiatRateFetching context.CancelFunc } // New is the constructor for a new Core. @@ -1227,6 +1233,8 @@ func New(cfg *Config) (*Core, error) { locale: locale, localePrinter: message.NewPrinter(lang), seedGenerationTime: seedGenerationTime, + + fiatRateSources: make(map[string]*commonRateSource), } // Populate the initial user data. User won't include any DEX info yet, as @@ -1259,6 +1267,36 @@ func (c *Core) Run(ctx context.Context) { c.latencyQ.Run(ctx) }() + // Skip rate fetch setup if on simnet. Rate fetching maybe enabled if + // desired. + if c.cfg.Net != dex.Simnet { + c.ratesMtx.Lock() + // Retrieve disabled fiat rate sources from database. + disabledSources, err := c.db.DisabledRateSources() + if err != nil { + c.log.Errorf("Unable to retrieve disabled fiat rate source: %v", err) + } + + // Construct enabled fiat rate sources. + fetchers: + for token, rateFetcher := range fiatRateFetchers { + for _, v := range disabledSources { + if token == v { + continue fetchers + } + } + c.fiatRateSources[token] = newCommonRateSource(rateFetcher) + } + + // Start goroutine for fiat rate fetcher's if we have at least one source. + if len(c.fiatRateSources) != 0 { + c.fetchFiatExchangeRates() + } else { + c.log.Debug("no fiat rate source initialized") + } + c.ratesMtx.Unlock() + } + c.wg.Wait() // block here until all goroutines except DB complete // Stop the DB after dexConnections and other goroutines are done. @@ -1707,6 +1745,7 @@ func (c *Core) User() *User { Exchanges: c.Exchanges(), Initialized: c.IsInitialized(), SeedGenerationTime: c.seedGenerationTime, + FiatRates: c.fiatConversions(), } } @@ -7723,3 +7762,217 @@ func (c *Core) findActiveOrder(oid order.OrderID) (*trackedTrade, error) { } return nil, fmt.Errorf("could not find active order with order id: %s", oid) } + +// fetchFiatExchangeRates starts the fiat rate fetcher goroutine and schedules +// refresh cycles. Use under ratesMtx lock. +func (c *Core) fetchFiatExchangeRates() { + if c.stopFiatRateFetching != nil { + c.log.Debug("Fiat exchange rate fetching is already enabled") + return + } + ctx, cancel := context.WithCancel(c.ctx) + c.stopFiatRateFetching = cancel + + c.log.Debug("starting fiat rate fetching") + + c.wg.Add(1) + go func() { + defer c.wg.Done() + tick := time.NewTimer(fiatRateRequestInterval) + defer tick.Stop() + for { + + c.refreshFiatRates(ctx) + + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } + }() +} + +// refreshFiatRates refreshes the fiat rates for rate sources whose values have +// not been updated since fiatRateRequestInterval. It also checks if fiat rates +// are expired and does some clean-up. +func (c *Core) refreshFiatRates(ctx context.Context) { + ctx, cancel := context.WithTimeout(ctx, 4*time.Second) + defer cancel() + + var wg sync.WaitGroup + supportedAssets := c.SupportedAssets() + c.ratesMtx.RLock() + for _, source := range c.fiatRateSources { + wg.Add(1) + go func(source *commonRateSource) { + defer wg.Done() + source.refreshRates(ctx, c.log, supportedAssets) + }(source) + } + c.ratesMtx.RUnlock() + wg.Wait() + + // Remove expired rate source if any. + c.removeExpiredRateSources() + + fiatRatesMap := c.fiatConversions() + if len(fiatRatesMap) != 0 { + c.notify(newFiatRatesUpdate(fiatRatesMap)) + } +} + +// FiatRateSources returns a list of fiat rate sources and their individual +// status. +func (c *Core) FiatRateSources() map[string]bool { + c.ratesMtx.RLock() + defer c.ratesMtx.RUnlock() + rateSources := make(map[string]bool, len(fiatRateFetchers)) + for token := range fiatRateFetchers { + rateSources[token] = c.fiatRateSources[token] != nil + } + return rateSources +} + +// fiatConversions returns fiat rate for all supported assets that have a +// wallet. +func (c *Core) fiatConversions() map[uint32]float64 { + supportedAssets := asset.Assets() + + c.ratesMtx.RLock() + defer c.ratesMtx.RUnlock() + fiatRatesMap := make(map[uint32]float64, len(supportedAssets)) + for assetID := range supportedAssets { + var rateSum float64 + var sources int + for _, source := range c.fiatRateSources { + rateInfo := source.assetRate(assetID) + if rateInfo != nil && time.Since(rateInfo.lastUpdate) < fiatRateDataExpiry { + sources++ + rateSum += rateInfo.rate + } + } + if rateSum != 0 { + fiatRatesMap[assetID] = rateSum / float64(sources) // get average rate. + } + } + return fiatRatesMap +} + +// ToggleRateSourceStatus toggles a fiat rate source status. If disable is true, +// the fiat rate source is disabled, otherwise the rate source is enabled. +func (c *Core) ToggleRateSourceStatus(source string, disable bool) error { + if disable { + return c.disableRateSource(source) + } + return c.enableRateSource(source) +} + +// enableRateSource enables a fiat rate source. +func (c *Core) enableRateSource(source string) error { + // Check if it's an invalid rate source or it is already enabled. + rateFetcher, found := fiatRateFetchers[source] + if !found { + return errors.New("cannot enable unknown fiat rate source") + } + + c.ratesMtx.Lock() + defer c.ratesMtx.Unlock() + if c.fiatRateSources[source] != nil { + return nil // already enabled. + } + + // Build fiat rate source. + rateSource := newCommonRateSource(rateFetcher) + c.fiatRateSources[source] = rateSource + + // If this is our first fiat rate source, start fiat rate fetcher goroutine, + // else fetch rates. + if len(c.fiatRateSources) == 1 { + c.fetchFiatExchangeRates() + } else { + go func() { + supportedAssets := c.SupportedAssets() // not with ratesMtx locked! + ctx, cancel := context.WithTimeout(c.ctx, 4*time.Second) + defer cancel() + rateSource.refreshRates(ctx, c.log, supportedAssets) + }() + } + + // Update disabled fiat rate source. + c.saveDisabledRateSources() + + c.log.Infof("Enabled %s to fetch fiat rates.", source) + return nil +} + +// disableRateSource disables a fiat rate source. +func (c *Core) disableRateSource(source string) error { + // Check if it's an invalid fiat rate source or it is already + // disabled. + _, found := fiatRateFetchers[source] + if !found { + return errors.New("cannot disable unknown fiat rate source") + } + + c.ratesMtx.Lock() + defer c.ratesMtx.Unlock() + + if c.fiatRateSources[source] == nil { + return nil // already disabled. + } + + // Remove fiat rate source. + delete(c.fiatRateSources, source) + + // Save disabled fiat rate sources to database. + c.saveDisabledRateSources() + + c.log.Infof("Disabled %s from fetching fiat rates.", source) + return nil +} + +// removeExpiredRateSources disables expired fiat rate source. +func (c *Core) removeExpiredRateSources() { + c.ratesMtx.Lock() + defer c.ratesMtx.Unlock() + + // Remove fiat rate source with expired exchange rate data. + var disabledSources []string + for token, source := range c.fiatRateSources { + if source.isExpired(fiatRateDataExpiry) { + delete(c.fiatRateSources, token) + disabledSources = append(disabledSources, token) + } + } + + // Ensure disabled fiat rate fetchers are saved to database. + if len(disabledSources) > 0 { + c.saveDisabledRateSources() + c.log.Warnf("Expired rate source(s) has been disabled: %v", strings.Join(disabledSources, ", ")) + } +} + +// saveDisabledRateSources saves disabled fiat rate sources to database and +// shuts down rate fetching if there are no exchange rate source. Use under +// ratesMtx lock. +func (c *Core) saveDisabledRateSources() { + var disabled []string + for token := range fiatRateFetchers { + if c.fiatRateSources[token] == nil { + disabled = append(disabled, token) + } + } + + // Shutdown rate fetching if there are no exchange rate source. + if len(c.fiatRateSources) == 0 && c.stopFiatRateFetching != nil { + c.stopFiatRateFetching() + c.stopFiatRateFetching = nil + c.log.Debug("shutting down rate fetching") + } + + err := c.db.SaveDisabledRateSources(disabled) + if err != nil { + c.log.Errorf("Unable to save disabled fiat rate source to database: %v", err) + } +} diff --git a/client/core/core_test.go b/client/core/core_test.go index 0187bbc8df..a9d5768eb6 100644 --- a/client/core/core_test.go +++ b/client/core/core_test.go @@ -32,7 +32,6 @@ import ( "decred.org/dcrdex/dex/encrypt" "decred.org/dcrdex/dex/msgjson" "decred.org/dcrdex/dex/order" - "decred.org/dcrdex/dex/order/test" ordertest "decred.org/dcrdex/dex/order/test" "decred.org/dcrdex/dex/wait" "decred.org/dcrdex/server/account" @@ -530,7 +529,12 @@ func (tdb *TDB) SetSeedGenerationTime(time uint64) error { func (tdb *TDB) SeedGenerationTime() (uint64, error) { return 0, nil } - +func (tdb *TDB) DisabledRateSources() ([]string, error) { + return nil, nil +} +func (tdb *TDB) SaveDisabledRateSources(disableSources []string) error { + return nil +} func (tdb *TDB) Recrypt(creds *db.PrimaryCredentials, oldCrypter, newCrypter encrypt.Crypter) ( walletUpdates map[uint32][]byte, acctUpdates map[string][]byte, err error) { @@ -1084,6 +1088,13 @@ func randomMsgMarket() (baseAsset, quoteAsset *msgjson.Asset) { return randomAsset(), randomAsset() } +func tFetcher(_ context.Context, log dex.Logger, _ map[uint32]*SupportedAsset) map[uint32]float64 { + return map[uint32]float64{ + tUTXOAssetA.ID: 45, + tUTXOAssetB.ID: 32000, + } +} + type testRig struct { shutdown func() core *Core @@ -1158,6 +1169,8 @@ func newTestRig() *testRig { locale: enUS, localePrinter: message.NewPrinter(language.AmericanEnglish), + + fiatRateSources: make(map[string]*commonRateSource), }, db: tdb, queue: queue, @@ -4839,7 +4852,7 @@ func TestReconcileTrades(t *testing.T) { clientOrders: []*trackedTrade{}, serverOrders: []*msgjson.OrderStatus{ { - ID: test.RandomOrderID().Bytes(), + ID: ordertest.RandomOrderID().Bytes(), Status: uint16(order.OrderStatusBooked), }, }, @@ -9266,3 +9279,108 @@ func TestLCM(t *testing.T) { } } } + +func TestToggleRateSourceStatus(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + tCore := rig.core + + tests := []struct { + name, source string + wantErr, init bool + }{{ + name: "Invalid rate source", + source: "binance", + wantErr: true, + }, { + name: "ok valid source", + source: messari, + wantErr: false, + }, { + name: "ok already disabled/not initialized || enabled", + source: messari, + wantErr: false, + }} + + // Test disabling fiat rate source. + for _, test := range tests { + err := tCore.ToggleRateSourceStatus(test.source, true) + if test.wantErr != (err != nil) { + t.Fatalf("%s: wantErr = %t, err = %v", test.name, test.wantErr, err) + } + } + + // Test enabling fiat rate source. + for _, test := range tests { + if test.init { + tCore.fiatRateSources[test.source] = newCommonRateSource(tFetcher) + } + err := tCore.ToggleRateSourceStatus(test.source, false) + if test.wantErr != (err != nil) { + t.Fatalf("%s: wantErr = %t, err = %v", test.name, test.wantErr, err) + } + } +} + +func TestFiatRateSources(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + tCore := rig.core + supportedFetchers := len(fiatRateFetchers) + rateSources := tCore.FiatRateSources() + if len(rateSources) != supportedFetchers { + t.Fatalf("Expected %d number of fiat rate source/fetchers", supportedFetchers) + } +} + +func TestFiatConversions(t *testing.T) { + rig := newTestRig() + defer rig.shutdown() + tCore := rig.core + ctx, cancel := context.WithCancel(tCore.ctx) + tCore.stopFiatRateFetching = cancel + + // No fiat rate source initialized + fiatRates := tCore.fiatConversions() + if len(fiatRates) != 0 { + t.Fatal("Unexpected asset rate values.") + } + + // Initialize fiat rate sources. + for token := range fiatRateFetchers { + tCore.fiatRateSources[token] = newCommonRateSource(tFetcher) + } + + // Fetch fiat rates. + tCore.wg.Add(1) + go func() { + defer tCore.wg.Done() + tCore.refreshFiatRates(ctx) + }() + tCore.wg.Wait() + + // Expects assets fiat rate values. + fiatRates = tCore.fiatConversions() + if len(fiatRates) != 2 { + t.Fatal("Expected assets fiat rate for two assets") + } + + // fiat rates for assets can expire, and fiat rate fetchers can be + // removed if expired. + for token, source := range tCore.fiatRateSources { + source.fiatRates[tUTXOAssetA.ID].lastUpdate = time.Now().Add(-time.Minute) + source.fiatRates[tUTXOAssetB.ID].lastUpdate = time.Now().Add(-time.Minute) + if source.isExpired(55 * time.Second) { + delete(tCore.fiatRateSources, token) + } + } + + fiatRates = tCore.fiatConversions() + if len(fiatRates) != 0 { + t.Fatal("Unexpected assets fiat rate values, expected to ignore expired fiat rates.") + } + + if len(tCore.fiatRateSources) != 0 { + t.Fatal("Expected fiat conversion to be disabled, all rate source data has expired.") + } +} diff --git a/client/core/exchangeratefetcher.go b/client/core/exchangeratefetcher.go new file mode 100644 index 0000000000..3d545b988e --- /dev/null +++ b/client/core/exchangeratefetcher.go @@ -0,0 +1,258 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package core + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "decred.org/dcrdex/dex" +) + +const ( + // DefaultFiatCurrency is the currency for displaying assets fiat value. + DefaultFiatCurrency = "USD" + // fiatRateRequestInterval is the amount of time between calls to the exchange API. + fiatRateRequestInterval = 5 * time.Minute + // fiatRateDataExpiry : Any data older than fiatRateDataExpiry will be discarded. + fiatRateDataExpiry = 60 * time.Minute + + // Tokens. Used to identify fiat rate source, source name must not contain a + // comma. + messari = "Messari" + coinpaprika = "Coinpaprika" + dcrdataDotOrg = "dcrdata" +) + +var ( + dcrDataURL = "https://explorer.dcrdata.org/api/exchangerate" + coinpaprikaURL = "https://api.coinpaprika.com/v1/tickers/%s" + messariURL = "https://data.messari.io/api/v1/assets/%s/metrics/market-data" + btcBipID, _ = dex.BipSymbolID("btc") + dcrBipID, _ = dex.BipSymbolID("dcr") +) + +// fiatRateFetchers is the list of all supported fiat rate fetchers. +var fiatRateFetchers = map[string]rateFetcher{ + coinpaprika: fetchCoinpaprikaRates, + dcrdataDotOrg: fetchDcrdataRates, + messari: fetchMessariRates, +} + +// fiatRateInfo holds the fiat rate and the last update time for an +// asset. +type fiatRateInfo struct { + rate float64 + lastUpdate time.Time +} + +// rateFetcher can fetch fiat rates for assets from an API. +type rateFetcher func(context context.Context, logger dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 + +type commonRateSource struct { + fetchRates rateFetcher + + mtx sync.RWMutex + fiatRates map[uint32]*fiatRateInfo +} + +// isExpired checks the last update time for all fiat rates against the +// provided expiryTime. This only returns true if all rates are expired. +func (source *commonRateSource) isExpired(expiryTime time.Duration) bool { + now := time.Now() + + source.mtx.RLock() + defer source.mtx.RUnlock() + if len(source.fiatRates) == 0 { + return false + } + for _, rateInfo := range source.fiatRates { + if now.Sub(rateInfo.lastUpdate) < expiryTime { + return false // one not expired is enough + } + } + return true +} + +// assetRate returns the fiat rate information for the assetID specified. The +// fiatRateInfo returned should not be modified by the caller. +func (source *commonRateSource) assetRate(assetID uint32) *fiatRateInfo { + source.mtx.RLock() + defer source.mtx.RUnlock() + return source.fiatRates[assetID] +} + +// refreshRates updates the last update time and the rate information for assets. +func (source *commonRateSource) refreshRates(ctx context.Context, logger dex.Logger, assets map[uint32]*SupportedAsset) { + fiatRates := source.fetchRates(ctx, logger, assets) + now := time.Now() + source.mtx.Lock() + defer source.mtx.Unlock() + for assetID, fiatRate := range fiatRates { + source.fiatRates[assetID] = &fiatRateInfo{ + rate: fiatRate, + lastUpdate: now, + } + } +} + +// Used to initialize a fiat rate source. +func newCommonRateSource(fetcher rateFetcher) *commonRateSource { + return &commonRateSource{ + fetchRates: fetcher, + fiatRates: make(map[uint32]*fiatRateInfo), + } +} + +// fetchCoinpaprikaRates retrieves and parses fiat rate data from the +// Coinpaprika API. See https://api.coinpaprika.com/#operation/getTickersById +// for sample request and response information. +func fetchCoinpaprikaRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 { + fiatRates := make(map[uint32]float64) + for assetID, asset := range assets { + if asset.Wallet == nil { + // we don't want to fetch rates for assets with no wallet. + continue + } + + res := new(struct { + Quotes struct { + Currency struct { + Price float64 `json:"price"` + } `json:"USD"` + } `json:"quotes"` + }) + + slug := fmt.Sprintf("%s-%s", asset.Symbol, asset.Info.Name) + // Special handling for asset names with multiple space, e.g Bitcoin Cash. + slug = strings.ToLower(strings.ReplaceAll(slug, " ", "-")) + reqStr := fmt.Sprintf(coinpaprikaURL, slug) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqStr, nil) + if err != nil { + log.Errorf("%s: NewRequestWithContext error: %v", coinpaprika, err) + continue + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + log.Errorf("%s: request failed: %v", coinpaprika, err) + continue + } + defer resp.Body.Close() + + // Read the raw bytes and close the response. + reader := io.LimitReader(resp.Body, 1<<20) + err = json.NewDecoder(reader).Decode(res) + if err != nil { + log.Errorf("%s: failed to decode json from %s: %v", coinpaprika, request.URL.String(), err) + continue + } + + fiatRates[assetID] = res.Quotes.Currency.Price + } + return fiatRates +} + +// fetchDcrdataRates retrieves and parses fiat rate data from dcrdata +// exchange rate API. +func fetchDcrdataRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 { + assetBTC := assets[btcBipID] + assetDCR := assets[dcrBipID] + noBTCAsset := assetBTC == nil || assetBTC.Wallet == nil + noDCRAsset := assetDCR == nil || assetDCR.Wallet == nil + if noBTCAsset && noDCRAsset { + return nil + } + + fiatRates := make(map[uint32]float64) + res := new(struct { + DcrPrice float64 `json:"dcrPrice"` + BtcPrice float64 `json:"btcPrice"` + }) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, dcrDataURL, nil) + if err != nil { + log.Errorf("%s: NewRequestWithContext error: %v", dcrdataDotOrg, err) + return nil + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + log.Errorf("%s: request failed: %v", dcrdataDotOrg, err) + return nil + } + defer resp.Body.Close() + + // Read the raw bytes and close the response. + reader := io.LimitReader(resp.Body, 1<<20) + err = json.NewDecoder(reader).Decode(res) + if err != nil { + log.Errorf("%s: failed to decode json from %s: %v", dcrdataDotOrg, request.URL.String(), err) + return nil + } + + if !noBTCAsset { + fiatRates[btcBipID] = res.BtcPrice + } + if !noDCRAsset { + fiatRates[dcrBipID] = res.DcrPrice + } + + return fiatRates +} + +// fetchMessariRates retrieves and parses fiat rate data from the Messari API. +// See https://messari.io/api/docs#operation/Get%20Asset%20Market%20Data for +// sample request and response information. +func fetchMessariRates(ctx context.Context, log dex.Logger, assets map[uint32]*SupportedAsset) map[uint32]float64 { + fiatRates := make(map[uint32]float64) + for assetID, asset := range assets { + if asset.Wallet == nil { + // we don't want to fetch rate for assets with no wallet. + continue + } + + res := new(struct { + Data struct { + MarketData struct { + Price float64 `json:"price_usd"` + } `json:"market_data"` + } `json:"data"` + }) + + slug := strings.ToLower(asset.Symbol) + reqStr := fmt.Sprintf(messariURL, slug) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqStr, nil) + if err != nil { + log.Errorf("%s: NewRequestWithContext error: %v", dcrdataDotOrg, err) + continue + } + + resp, err := http.DefaultClient.Do(request) + if err != nil { + log.Errorf("%s: request error: %v", messari, err) + continue + } + defer resp.Body.Close() + + // Read the raw bytes and close the response. + reader := io.LimitReader(resp.Body, 1<<20) + err = json.NewDecoder(reader).Decode(res) + if err != nil { + log.Errorf("%s: failed to decode json from %s: %v", messari, request.URL.String(), err) + continue + } + + fiatRates[assetID] = res.Data.MarketData.Price + } + return fiatRates +} diff --git a/client/core/notification.go b/client/core/notification.go index 88dc9ab1c8..c24a78e589 100644 --- a/client/core/notification.go +++ b/client/core/notification.go @@ -28,6 +28,7 @@ const ( NoteTypeSecurity = "security" NoteTypeUpgrade = "upgrade" NoteTypeDEXAuth = "dex_auth" + NoteTypeFiatRates = "fiatrateupdate" ) func (c *Core) logNote(n Notification) { @@ -355,6 +356,21 @@ func newConnEventNote(topic Topic, subject, host string, status comms.Connection } } +// FiatRatesNote is an update of fiat rate data for assets. +type FiatRatesNote struct { + db.Notification + FiatRates map[uint32]float64 `json:"fiatRates"` +} + +const TopicFiatRatesUpdate Topic = "fiatrateupdate" + +func newFiatRatesUpdate(rates map[uint32]float64) *FiatRatesNote { + return &FiatRatesNote{ + Notification: db.NewNotification(NoteTypeFiatRates, TopicFiatRatesUpdate, "", "", db.Data), + FiatRates: rates, + } +} + // BalanceNote is an update to a wallet's balance. type BalanceNote struct { db.Notification diff --git a/client/core/types.go b/client/core/types.go index 29e1c787f6..343f508e8b 100644 --- a/client/core/types.go +++ b/client/core/types.go @@ -117,6 +117,7 @@ type User struct { Initialized bool `json:"inited"` SeedGenerationTime uint64 `json:"seedgentime"` Assets map[uint32]*SupportedAsset `json:"assets"` + FiatRates map[uint32]float64 `json:"fiatRates"` } // SupportedAsset is data about an asset and possibly the wallet associated diff --git a/client/db/bolt/db.go b/client/db/bolt/db.go index a43c31fdf4..013af5267d 100644 --- a/client/db/bolt/db.go +++ b/client/db/bolt/db.go @@ -12,9 +12,9 @@ import ( "os" "path/filepath" "sort" + "strings" "time" - "decred.org/dcrdex/client/db" dexdb "decred.org/dcrdex/client/db" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/config" @@ -103,6 +103,7 @@ var ( refundReservesKey = []byte("refundReservesKey") byteTrue = encode.ByteTrue backupDir = "backup" + disabledRateSourceKey = []byte("disabledRateSources") ) // BoltDB is a bbolt-based database backend for a DEX client. BoltDB satisfies @@ -878,7 +879,7 @@ func (fs filterSet) check(oidB []byte, oBkt *bbolt.Bucket) bool { // Orders fetches a slice of orders, sorted by descending time, and filtered // with the provided OrderFilter. Orders does not return cancel orders. -func (db *BoltDB) Orders(orderFilter *db.OrderFilter) (ords []*dexdb.MetaOrder, err error) { +func (db *BoltDB) Orders(orderFilter *dexdb.OrderFilter) (ords []*dexdb.MetaOrder, err error) { // Default filter is just to exclude cancel orders. filters := filterSet{ func(oidB []byte, oBkt *bbolt.Bucket) bool { @@ -1104,7 +1105,7 @@ func updateOrderBucket(ob, archivedOB *bbolt.Bucket, oid order.OrderID, status o } // UpdateOrderMetaData updates the order metadata, not including the Host. -func (db *BoltDB) UpdateOrderMetaData(oid order.OrderID, md *db.OrderMetaData) error { +func (db *BoltDB) UpdateOrderMetaData(oid order.OrderID, md *dexdb.OrderMetaData) error { return db.ordersUpdate(func(ob, archivedOB *bbolt.Bucket) error { oBkt, err := updateOrderBucket(ob, archivedOB, oid, md.Status) if err != nil { @@ -1483,7 +1484,7 @@ func (db *BoltDB) SetWalletPassword(wid []byte, newEncPW []byte) error { } // UpdateBalance updates balance in the wallet bucket. -func (db *BoltDB) UpdateBalance(wid []byte, bal *db.Balance) error { +func (db *BoltDB) UpdateBalance(wid []byte, bal *dexdb.Balance) error { return db.walletsUpdate(func(master *bbolt.Bucket) error { wBkt := master.Bucket(wid) if wBkt == nil { @@ -1534,7 +1535,7 @@ func makeWallet(wBkt *bbolt.Bucket) (*dexdb.Wallet, error) { balB := getCopy(wBkt, balanceKey) if balB != nil { - bal, err := db.DecodeBalance(balB) + bal, err := dexdb.DecodeBalance(balB) if err != nil { return nil, fmt.Errorf("DecodeBalance error: %w", err) } @@ -1838,7 +1839,7 @@ func (idx *timeIndexNewest) add(t uint64, k []byte, b *bbolt.Bucket) { // operations. Optionally accepts a time to delete orders with a later time // stamp. Accepts an optional function to perform on deleted orders. func (db *BoltDB) DeleteInactiveOrders(ctx context.Context, olderThan *time.Time, - perOrderFn func(ords *db.MetaOrder) error) error { + perOrderFn func(ords *dexdb.MetaOrder) error) error { const batchSize = 1000 var ( finished bool @@ -1979,7 +1980,7 @@ func orderSide(tx *bbolt.Tx, oid order.OrderID) (sell bool, err error) { // operations. Optionally accepts a time to delete matchess with a later time // stamp. Accepts an optional function to perform on deleted matches. func (db *BoltDB) DeleteInactiveMatches(ctx context.Context, olderThan *time.Time, - perMatchFn func(mtch *db.MetaMatch, isSell bool) error) error { + perMatchFn func(mtch *dexdb.MetaMatch, isSell bool) error) error { const batchSize = 1000 var ( finished bool @@ -2075,6 +2076,41 @@ func (db *BoltDB) DeleteInactiveMatches(ctx context.Context, olderThan *time.Tim return nil } +// SaveDisabledRateSources updates disabled fiat rate sources. +func (db *BoltDB) SaveDisabledRateSources(disabledSources []string) error { + return db.Update(func(tx *bbolt.Tx) error { + bkt := tx.Bucket(appBucket) + if bkt == nil { + return fmt.Errorf("failed to open %s bucket", string(appBucket)) + } + return bkt.Put(disabledRateSourceKey, []byte(strings.Join(disabledSources, ","))) + }) +} + +// DisabledRateSources retrieves a map of disabled fiat rate sources. +func (db *BoltDB) DisabledRateSources() (disabledSources []string, err error) { + return disabledSources, db.View(func(tx *bbolt.Tx) error { + bkt := tx.Bucket(appBucket) + if bkt == nil { + return fmt.Errorf("no %s bucket", string(appBucket)) + } + + disabledString := string(bkt.Get(disabledRateSourceKey)) + if disabledString == "" { + return nil + } + + disabled := strings.Split(disabledString, ",") + disabledSources = make([]string, len(disabled)) + for _, token := range disabled { + if token != "" { + disabledSources = append(disabledSources, token) + } + } + return nil + }) +} + // timeNow is the current unix timestamp in milliseconds. func timeNow() uint64 { return uint64(time.Now().UnixMilli()) diff --git a/client/db/interface.go b/client/db/interface.go index c02dc989fb..6440de7d45 100644 --- a/client/db/interface.go +++ b/client/db/interface.go @@ -130,4 +130,10 @@ type DB interface { SetSeedGenerationTime(time uint64) error // SeedGenerationTime fetches the time when the app seed was generated. SeedGenerationTime() (uint64, error) + // DisabledRateSources retrieves disabled fiat rate sources from the + // database. + DisabledRateSources() ([]string, error) + // SaveDisabledRateSources saves disabled fiat rate sources in the database. + // A source name must not contain a comma. + SaveDisabledRateSources(disabledSources []string) error } diff --git a/client/webserver/api.go b/client/webserver/api.go index 713cd294b6..c6b97e65b9 100644 --- a/client/webserver/api.go +++ b/client/webserver/api.go @@ -1068,6 +1068,23 @@ func (s *WebServer) apiUser(w http.ResponseWriter, r *http.Request) { writeJSON(w, response, s.indent) } +// apiToggleRateSource handles the /toggleratesource API request. +func (s *WebServer) apiToggleRateSource(w http.ResponseWriter, r *http.Request) { + form := &struct { + Disable bool `json:"disable"` + Source string `json:"source"` + }{} + if !readPost(w, r, form) { + return + } + err := s.core.ToggleRateSourceStatus(form.Source, form.Disable) + if err != nil { + s.writeAPIError(w, fmt.Errorf("error disabling/enabling rate source: %w", err)) + return + } + writeJSON(w, simpleAck(), s.indent) +} + // writeAPIError logs the formatted error and sends a standardResponse with the // error message. func (s *WebServer) writeAPIError(w http.ResponseWriter, err error) { diff --git a/client/webserver/http.go b/client/webserver/http.go index 23e97e58bd..cdf8b78be4 100644 --- a/client/webserver/http.go +++ b/client/webserver/http.go @@ -241,10 +241,14 @@ func (s *WebServer) handleSettings(w http.ResponseWriter, r *http.Request) { common := commonArgs(r, "Settings | Decred DEX") data := &struct { CommonArguments - KnownExchanges []string + KnownExchanges []string + FiatRateSources map[string]bool + FiatCurrency string }{ CommonArguments: *common, KnownExchanges: s.knownUnregisteredExchanges(common.UserInfo.Exchanges), + FiatCurrency: core.DefaultFiatCurrency, + FiatRateSources: s.core.FiatRateSources(), } s.sendTemplate(w, "settings", data) } diff --git a/client/webserver/live_test.go b/client/webserver/live_test.go index 50a47aba6c..8ddf2eaa4a 100644 --- a/client/webserver/live_test.go +++ b/client/webserver/live_test.go @@ -436,6 +436,7 @@ type TCore struct { noteFeed chan core.Notification orderMtx sync.Mutex epochOrders []*core.BookUpdate + fiatSources map[string]bool } // TDriver implements the interface required of all exchange wallets. @@ -481,6 +482,11 @@ func newTCore() *TCore { 145: randomBalance(145), }, noteFeed: make(chan core.Notification, 1), + fiatSources: map[string]bool{ + "dcrdata": true, + "Messari": true, + "Coinpaprika": true, + }, } } @@ -1400,6 +1406,17 @@ func (c *TCore) User() *core.User { Exchanges: exchanges, Initialized: c.inited, Assets: c.SupportedAssets(), + FiatRates: map[uint32]float64{ + 0: 21_208.61, // btc + 2: 59.08, // ltc + 42: 25.46, // dcr + 22: 0.5117, // mona + 28: 0.1599, // vtc + 141: 0.2048, // kmd + 3: 0.06769, // doge + 145: 114.68, // bch + 60: 1_209.51, // eth + }, } return user } @@ -1615,6 +1632,13 @@ func (c *TCore) UpdateDEXHost(string, string, []byte, interface{}) (*core.Exchan func (c *TCore) WalletRestorationInfo(pw []byte, assetID uint32) ([]*asset.WalletRestoration, error) { return nil, nil } +func (c *TCore) ToggleRateSourceStatus(src string, disable bool) error { + c.fiatSources[src] = !disable + return nil +} +func (c *TCore) FiatRateSources() map[string]bool { + return c.fiatSources +} func TestServer(t *testing.T) { // Register dummy drivers for unimplemented assets. diff --git a/client/webserver/locales/en-us.go b/client/webserver/locales/en-us.go index 0f6bcca1b9..1c77123ed8 100644 --- a/client/webserver/locales/en-us.go +++ b/client/webserver/locales/en-us.go @@ -243,4 +243,5 @@ var EnUS = map[string]string{ "export_wallet_disclaimer": `Using an externally restored wallet while you have active trades running in the DEX could result in failed trades and LOST FUNDS. It is recommended that you do not export your wallet unless you are an experienced user and you know what are doing.`, "export_wallet_msg": "Below are the seeds needed to restore your wallet in some popular external wallets. DO NOT make transactions with your external wallet while you have active trades running on the DEX.", "clipboard_warning": "Copy/Pasting a wallet seed is a potential security risk. Do this at your own risk.", + "fiat_exchange_rate_sources": "Fiat Exchange Rate Sources", } diff --git a/client/webserver/site/src/html/bodybuilder.tmpl b/client/webserver/site/src/html/bodybuilder.tmpl index cf183f8657..67402d91be 100644 --- a/client/webserver/site/src/html/bodybuilder.tmpl +++ b/client/webserver/site/src/html/bodybuilder.tmpl @@ -8,7 +8,7 @@