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 @@ {{.Title}} - + + + ~USD +
@@ -462,6 +465,9 @@
+ + ~USD +
[[[Receiving Approximately]]] @@ -470,6 +476,9 @@
+ + ~USD +

diff --git a/client/webserver/site/src/html/settings.tmpl b/client/webserver/site/src/html/settings.tmpl index b8fdcfa2e5..ccc4ac5f88 100644 --- a/client/webserver/site/src/html/settings.tmpl +++ b/client/webserver/site/src/html/settings.tmpl @@ -16,6 +16,24 @@ [[[Show pop-up notifications]]] +
+ Fiat Currency: {{.FiatCurrency}} +
+
+ + [[[fiat_exchange_rate_sources]]]: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
[[[registered dexes]]]
diff --git a/client/webserver/site/src/html/wallets.tmpl b/client/webserver/site/src/html/wallets.tmpl index 6ebf8e9c06..dd9d4d1d90 100644 --- a/client/webserver/site/src/html/wallets.tmpl +++ b/client/webserver/site/src/html/wallets.tmpl @@ -71,9 +71,13 @@ {{.Info.Name}} ({{toUpper .Symbol}}) - + {{if .Wallet}} +
{{.Info.UnitInfo.ConventionalString .Wallet.Balance.Available}} +
+
USD +
{{else}} 0.00000000 {{end}} @@ -164,6 +168,7 @@
+ ~USD
diff --git a/client/webserver/site/src/js/app.ts b/client/webserver/site/src/js/app.ts index 7e51d96787..db1d75f89c 100644 --- a/client/webserver/site/src/js/app.ts +++ b/client/webserver/site/src/js/app.ts @@ -35,7 +35,8 @@ import { LogMessage, NoteElement, BalanceResponse, - APIResponse + APIResponse, + RateNote } from './registry' const idel = Doc.idel // = element by id @@ -90,6 +91,7 @@ export default class Application { assets: Record exchanges: Record walletMap: Record + fiatRatesMap: Record tooltip: HTMLElement page: Record loadedPage: Page | null @@ -101,12 +103,14 @@ export default class Application { this.notes = [] this.pokes = [] // The "user" is a large data structure that contains nearly all state - // information, including exchanges, markets, wallets, and orders. + // information, including exchanges, markets, wallets, orders and exchange + // rates for assets. this.user = { exchanges: {}, inited: false, seedgentime: 0, assets: {}, + fiatRates: {}, authed: false, ok: true } @@ -223,11 +227,13 @@ export default class Application { this.assets = user.assets this.exchanges = user.exchanges this.walletMap = {} + this.fiatRatesMap = user.fiatRates for (const [assetID, asset] of (Object.entries(user.assets) as [any, SupportedAsset][])) { if (asset.wallet) { this.walletMap[assetID] = asset.wallet } } + this.updateMenuItemsDisplay() return user } @@ -571,8 +577,9 @@ export default class Application { const asset = this.assets[wallet.assetID] asset.wallet = wallet this.walletMap[wallet.assetID] = wallet + const bal = wallet.balance.available const balances = this.main.querySelectorAll(`[data-balance-target="${wallet.assetID}"]`) - balances.forEach(el => { el.textContent = Doc.formatFullPrecision(wallet.balance.available, asset.info.unitinfo) }) + balances.forEach(el => { el.textContent = Doc.formatFullPrecision(bal, asset.info.unitinfo) }) break } case 'match': { @@ -593,6 +600,10 @@ export default class Application { // Spots can come before the user is fetched after login. if (!xc) break for (const [mktName, spot] of Object.entries(n.spots)) xc.markets[mktName].spot = spot + break + } + case 'fiatrateupdate': { + this.fiatRatesMap = (note as RateNote).fiatRates } } diff --git a/client/webserver/site/src/js/doc.ts b/client/webserver/site/src/js/doc.ts index 20f38207fe..e242efaacc 100644 --- a/client/webserver/site/src/js/doc.ts +++ b/client/webserver/site/src/js/doc.ts @@ -235,6 +235,18 @@ export default class Doc { return fullPrecisionFormatter(prec).format(v) } + /* + * formatFiatConversion formats the value in atomic units to its representation in + * conventional units and returns the fiat value as a string. + */ + static formatFiatConversion (vAtomic: number, rate: number, unitInfo?: UnitInfo): string { + if (!rate || rate === 0) return 'unavailable' + const prec = 2 + const [v] = convertToConventional(vAtomic, unitInfo) + const value = v * rate + return fullPrecisionFormatter(prec).format(value) + } + /* * logoPath creates a path to a png logo for the specified ticker symbol. If * the symbol is not a supported asset, the generic letter logo will be diff --git a/client/webserver/site/src/js/markets.ts b/client/webserver/site/src/js/markets.ts index 54447b7cbb..4f810d7d35 100644 --- a/client/webserver/site/src/js/markets.ts +++ b/client/webserver/site/src/js/markets.ts @@ -1399,7 +1399,10 @@ export default class MarketsPage extends BasePage { page.vOrderType.textContent = order.tifnow ? orderDesc + ' (immediate)' : orderDesc page.vRate.textContent = Doc.formatCoinValue(order.rate / this.market.rateConversionFactor) page.vQty.textContent = Doc.formatCoinValue(order.qty, baseAsset.info.unitinfo) - page.vTotal.textContent = Doc.formatCoinValue(order.rate / OrderUtil.RateEncodingFactor * order.qty, quoteAsset.info.unitinfo) + const total = order.rate / OrderUtil.RateEncodingFactor * order.qty + page.vTotal.textContent = Doc.formatCoinValue(total, quoteAsset.info.unitinfo) + // Format total fiat value. + this.showFiatValue(quoteAsset.id, total, page.vFiatTotal) } else { Doc.hide(page.verifyLimit) Doc.show(page.verifyMarket) @@ -1407,12 +1410,16 @@ export default class MarketsPage extends BasePage { const ui = order.sell ? this.market.baseUnitInfo : this.market.quoteUnitInfo page.vmFromTotal.textContent = Doc.formatCoinValue(order.qty, ui) page.vmFromAsset.textContent = fromAsset.symbol.toUpperCase() + // Format fromAsset fiat value. + this.showFiatValue(fromAsset.id, order.qty, page.vmFromTotalFiat) const gap = this.midGap() if (gap) { Doc.show(page.vMarketEstimate) const received = order.sell ? order.qty * gap : order.qty / gap page.vmToTotal.textContent = Doc.formatCoinValue(received, toAsset.info.unitinfo) page.vmToAsset.textContent = toAsset.symbol.toUpperCase() + // Format recieved value to fiat equivalent. + this.showFiatValue(toAsset.id, received, page.vmTotalFiat) } else { Doc.hide(page.vMarketEstimate) } @@ -1442,6 +1449,16 @@ export default class MarketsPage extends BasePage { } } + // showFiatValue displays the fiat equivalent for an order quantity. + showFiatValue (assetID: number, qty: number, display: PageElement) { + if (display) { + const rate = app().fiatRatesMap[assetID] + display.textContent = Doc.formatFiatConversion(qty, rate, app().unitInfo(assetID)) + if (rate) Doc.show(display.parentElement as Element) + else Doc.hide(display.parentElement as Element) + } + } + /* showVerifyForm displays form to verify an order */ async showVerifyForm () { const page = this.page diff --git a/client/webserver/site/src/js/registry.ts b/client/webserver/site/src/js/registry.ts index ca0b4625d6..7849736702 100644 --- a/client/webserver/site/src/js/registry.ts +++ b/client/webserver/site/src/js/registry.ts @@ -239,6 +239,7 @@ export interface User { inited: boolean seedgentime: number assets: Record + fiatRates: Record authed: boolean // added by webserver ok: boolean // added by webserver } @@ -265,6 +266,10 @@ export interface BalanceNote extends CoreNote { balance: WalletBalance } +export interface RateNote extends CoreNote { + fiatRates: Record +} + export interface WalletConfigNote extends CoreNote { wallet: WalletState } @@ -465,6 +470,7 @@ export interface Application { header: HTMLElement walletMap: Record exchanges: Record + fiatRatesMap: Record showPopups: boolean commitHash: string start (): Promise diff --git a/client/webserver/site/src/js/settings.ts b/client/webserver/site/src/js/settings.ts index b61af104ce..8dd262e4e7 100644 --- a/client/webserver/site/src/js/settings.ts +++ b/client/webserver/site/src/js/settings.ts @@ -18,6 +18,7 @@ export default class SettingsPage extends BasePage { currentDEX: Exchange page: Record forms: PageElement[] + fiatRateSources: PageElement[] regAssetForm: forms.FeeAssetSelectionForm confirmRegisterForm: forms.ConfirmRegistrationForm newWalletForm: forms.NewWalletForm @@ -35,6 +36,7 @@ export default class SettingsPage extends BasePage { const page = this.page = Doc.idDescendants(body) this.forms = Doc.applySelector(page.forms, ':scope > form') + this.fiatRateSources = Doc.applySelector(page.fiatRateSources, 'input[type=checkbox]') Doc.bind(page.darkMode, 'click', () => { State.dark(page.darkMode.checked || false) @@ -57,6 +59,20 @@ export default class SettingsPage extends BasePage { this.showForm(page.dexAddrForm) }) + this.fiatRateSources.forEach(src => { + Doc.bind(src, 'change', async () => { + const res = await postJSON('/api/toggleratesource', { + disable: !src.checked, + source: src.value + }) + if (!app().checkResponse(res)) { + src.checked = !src.checked + } + // Update asset rate values and disable conversion status. + await app().fetchUser() + }) + }) + // Asset selection this.regAssetForm = new forms.FeeAssetSelectionForm(page.regAssetForm, async assetID => { this.confirmRegisterForm.setAsset(assetID) diff --git a/client/webserver/site/src/js/wallets.ts b/client/webserver/site/src/js/wallets.ts index bd9b02af4c..20894b2230 100644 --- a/client/webserver/site/src/js/wallets.ts +++ b/client/webserver/site/src/js/wallets.ts @@ -12,7 +12,9 @@ import { WalletDefinition, BalanceNote, WalletStateNote, - Market + Market, + RateNote, + WalletState } from './registry' const bind = Doc.bind @@ -214,13 +216,31 @@ export default class WalletsPage extends BasePage { // amount field. bind(page.sendAvail, 'click', () => { const asset = this.sendAsset - page.sendAmt.value = String(asset.wallet.balance.available / asset.info.unitinfo.conventional.conversionFactor) + const bal = asset.wallet.balance.available + page.sendAmt.value = String(bal / asset.info.unitinfo.conventional.conversionFactor) + this.showFiatValue(asset.id, bal, page.sendValue) // Ensure we don't check subtract checkbox for assets that don't have a // withdraw method. if ((asset.wallet.traits & traitWithdrawer) === 0) page.subtractCheckBox.checked = false else page.subtractCheckBox.checked = true }) + for (const [assetID, wallet] of (Object.entries(app().walletMap) as [any, WalletState][])) { + if (!wallet) continue + const fiatDisplay = this.page.walletTable.querySelector(`[data-conversion-target="${assetID}"]`) as PageElement + if (!fiatDisplay) continue + this.showFiatValue(assetID, wallet.balance.available, fiatDisplay) + } + + // Display fiat value for current send amount. + bind(page.sendAmt, 'input', () => { + const asset = this.sendAsset + if (!asset) return + const amt = parseFloat(page.sendAmt.value || '0') + const conversionFactor = asset.info.unitinfo.conventional.conversionFactor + this.showFiatValue(asset.id, amt * conversionFactor, page.sendValue) + }) + // A link on the wallet reconfiguration form to show/hide the password field. bind(page.showChangePW, 'click', () => { this.changeWalletPW = !this.changeWalletPW @@ -243,6 +263,7 @@ export default class WalletsPage extends BasePage { this.showMarkets(firstRow.assetID) app().registerNoteFeeder({ + fiatrateupdate: (note: RateNote) => { this.handleRatesNote(note) }, balance: (note: BalanceNote) => { this.handleBalanceNote(note) }, walletstate: (note: WalletStateNote) => { this.handleWalletStateNote(note) }, walletconfig: (note: WalletStateNote) => { this.handleWalletStateNote(note) } @@ -566,8 +587,9 @@ export default class WalletsPage extends BasePage { page.sendAddr.value = '' page.sendAmt.value = '' page.sendPW.value = '' - page.sendErr.textContent = '' + + this.showFiatValue(asset.id, 0, page.sendValue) page.sendAvail.textContent = Doc.formatFullPrecision(wallet.balance.available, asset.info.unitinfo) page.sendLogo.src = Doc.logoPath(asset.symbol) page.sendName.textContent = asset.info.name @@ -785,10 +807,38 @@ export default class WalletsPage extends BasePage { } } - /* handleBalance handles notifications updating a wallet's balance. */ + /* handleBalance handles notifications updating a wallet's balance and assets' + value in default fiat rate. + . */ handleBalanceNote (note: BalanceNote) { const td = Doc.safeSelector(this.page.walletTable, `[data-balance-target="${note.assetID}"]`) td.textContent = Doc.formatFullPrecision(note.balance.available, app().unitInfo(note.assetID)) + const fiatDisplay = Doc.safeSelector(this.page.walletTable, `[data-conversion-target="${note.assetID}"]`) + if (!fiatDisplay) return + this.showFiatValue(note.assetID, note.balance.available, fiatDisplay) + } + + /* handleRatesNote handles fiat rate notifications, updating the fiat value of + * all supported assets. + */ + handleRatesNote (note: RateNote) { + app().fiatRatesMap = note.fiatRates + for (const [assetID, wallet] of (Object.entries(app().walletMap) as [any, WalletState][])) { + if (!wallet) continue + const fiatDisplay = this.page.walletTable.querySelector(`[data-conversion-target="${assetID}"]`) as PageElement + if (!fiatDisplay) continue + this.showFiatValue(assetID, wallet.balance.available, fiatDisplay) + } + } + + // showFiatValue displays the fiat equivalent for the provided amount. + showFiatValue (assetID: number, amount: number, display: PageElement) { + if (display) { + const rate = app().fiatRatesMap[assetID] + display.textContent = Doc.formatFiatConversion(amount, rate, app().unitInfo(assetID)) + if (rate) Doc.show(display.parentElement as Element) + else Doc.hide(display.parentElement as Element) + } } /* @@ -797,6 +847,9 @@ export default class WalletsPage extends BasePage { */ handleWalletStateNote (note: WalletStateNote) { this.rowInfos[note.wallet.assetID].stateIcons.readWallet(note.wallet) + const fiatDisplay = this.page.walletTable.querySelector(`[data-conversion-target="${note.wallet.assetID}"]`) as PageElement + if (!fiatDisplay) return + this.showFiatValue(note.wallet.assetID, note.wallet.balance.available, fiatDisplay) } /* diff --git a/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl b/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl index c1a83a95ee..fc9f8f5cd5 100644 --- a/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/en-US/bodybuilder.tmpl @@ -8,7 +8,7 @@ {{.Title}} - + + + ~USD +
@@ -462,6 +465,9 @@
+ + ~USD +
Receiving Approximately @@ -470,6 +476,9 @@
+ + ~USD +

diff --git a/client/webserver/site/src/localized_html/en-US/settings.tmpl b/client/webserver/site/src/localized_html/en-US/settings.tmpl index 9e9907c588..94a0858612 100644 --- a/client/webserver/site/src/localized_html/en-US/settings.tmpl +++ b/client/webserver/site/src/localized_html/en-US/settings.tmpl @@ -16,6 +16,24 @@ Show pop-up notifications +
+ Fiat Currency: {{.FiatCurrency}} +
+
+ + Fiat Exchange Rate Sources: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
Registered Dexes:
diff --git a/client/webserver/site/src/localized_html/en-US/wallets.tmpl b/client/webserver/site/src/localized_html/en-US/wallets.tmpl index 6f91267399..a450e138bd 100644 --- a/client/webserver/site/src/localized_html/en-US/wallets.tmpl +++ b/client/webserver/site/src/localized_html/en-US/wallets.tmpl @@ -71,9 +71,13 @@ {{.Info.Name}} ({{toUpper .Symbol}}) - + {{if .Wallet}} +
{{.Info.UnitInfo.ConventionalString .Wallet.Balance.Available}} +
+
USD +
{{else}} 0.00000000 {{end}} @@ -164,6 +168,7 @@
+ ~USD
diff --git a/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl b/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl index 4e48c8040a..8babf33a24 100644 --- a/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/pl-PL/bodybuilder.tmpl @@ -8,7 +8,7 @@ {{.Title}} - + + + ~USD +
@@ -462,6 +465,9 @@
+ + ~USD +
Otrzymując około @@ -470,6 +476,9 @@
+ + ~USD +

diff --git a/client/webserver/site/src/localized_html/pl-PL/settings.tmpl b/client/webserver/site/src/localized_html/pl-PL/settings.tmpl index 734b5984ec..55150c5242 100644 --- a/client/webserver/site/src/localized_html/pl-PL/settings.tmpl +++ b/client/webserver/site/src/localized_html/pl-PL/settings.tmpl @@ -16,6 +16,24 @@ Pokazuj powiadomienia w okienkach +
+ Fiat Currency: {{.FiatCurrency}} +
+
+ + Fiat Exchange Rate Sources: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
Registered Dexes:
diff --git a/client/webserver/site/src/localized_html/pl-PL/wallets.tmpl b/client/webserver/site/src/localized_html/pl-PL/wallets.tmpl index 7505bfff0f..fe2e913d53 100644 --- a/client/webserver/site/src/localized_html/pl-PL/wallets.tmpl +++ b/client/webserver/site/src/localized_html/pl-PL/wallets.tmpl @@ -71,9 +71,13 @@ {{.Info.Name}} ({{toUpper .Symbol}}) - + {{if .Wallet}} +
{{.Info.UnitInfo.ConventionalString .Wallet.Balance.Available}} +
+
USD +
{{else}} 0.00000000 {{end}} @@ -164,6 +168,7 @@
+ ~USD
diff --git a/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl b/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl index 1da77e3447..111c3a5349 100644 --- a/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/pt-BR/bodybuilder.tmpl @@ -8,7 +8,7 @@ {{.Title}} - + + + ~USD +
@@ -462,6 +465,9 @@
+ + ~USD +
Recebendo aproximadamente @@ -470,6 +476,9 @@
+ + ~USD +

diff --git a/client/webserver/site/src/localized_html/pt-BR/settings.tmpl b/client/webserver/site/src/localized_html/pt-BR/settings.tmpl index d6ef83ffb7..06d4cf0956 100644 --- a/client/webserver/site/src/localized_html/pt-BR/settings.tmpl +++ b/client/webserver/site/src/localized_html/pt-BR/settings.tmpl @@ -16,6 +16,24 @@ Mostrar notificações de pop-up +
+ Fiat Currency: {{.FiatCurrency}} +
+
+ + Fiat Exchange Rate Sources: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
Registered Dexes:
diff --git a/client/webserver/site/src/localized_html/pt-BR/wallets.tmpl b/client/webserver/site/src/localized_html/pt-BR/wallets.tmpl index 5794e83a0e..8b8a6e359d 100644 --- a/client/webserver/site/src/localized_html/pt-BR/wallets.tmpl +++ b/client/webserver/site/src/localized_html/pt-BR/wallets.tmpl @@ -71,9 +71,13 @@ {{.Info.Name}} ({{toUpper .Symbol}}) - + {{if .Wallet}} +
{{.Info.UnitInfo.ConventionalString .Wallet.Balance.Available}} +
+
USD +
{{else}} 0.00000000 {{end}} @@ -164,6 +168,7 @@
+ ~USD
diff --git a/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl b/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl index 040e13b398..4d368aa23c 100644 --- a/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl +++ b/client/webserver/site/src/localized_html/zh-CN/bodybuilder.tmpl @@ -8,7 +8,7 @@ {{.Title}} - + + + ~USD +
@@ -462,6 +465,9 @@
+ + ~USD +
Receiving Approximately @@ -470,6 +476,9 @@
+ + ~USD +

diff --git a/client/webserver/site/src/localized_html/zh-CN/settings.tmpl b/client/webserver/site/src/localized_html/zh-CN/settings.tmpl index ffcfe7134d..61b814dacf 100644 --- a/client/webserver/site/src/localized_html/zh-CN/settings.tmpl +++ b/client/webserver/site/src/localized_html/zh-CN/settings.tmpl @@ -16,6 +16,24 @@ 显示弹出通知 +
+ Fiat Currency: {{.FiatCurrency}} +
+
+ + Fiat Exchange Rate Sources: + + + {{range $source, $enabled := .FiatRateSources}} +
+ + +
+ {{end}} +
Registered Dexes:
diff --git a/client/webserver/site/src/localized_html/zh-CN/wallets.tmpl b/client/webserver/site/src/localized_html/zh-CN/wallets.tmpl index 5121566909..55f45dde5c 100644 --- a/client/webserver/site/src/localized_html/zh-CN/wallets.tmpl +++ b/client/webserver/site/src/localized_html/zh-CN/wallets.tmpl @@ -71,9 +71,13 @@ {{.Info.Name}} ({{toUpper .Symbol}}) - + {{if .Wallet}} +
{{.Info.UnitInfo.ConventionalString .Wallet.Balance.Available}} +
+
USD +
{{else}} 0.00000000 {{end}} @@ -164,6 +168,7 @@
+ ~USD
diff --git a/client/webserver/webserver.go b/client/webserver/webserver.go index 827663a8a6..a1307eaed3 100644 --- a/client/webserver/webserver.go +++ b/client/webserver/webserver.go @@ -120,6 +120,8 @@ type clientCore interface { UpdateCert(host string, cert []byte) error UpdateDEXHost(oldHost, newHost string, appPW []byte, certI interface{}) (*core.Exchange, error) WalletRestorationInfo(pw []byte, assetID uint32) ([]*asset.WalletRestoration, error) + ToggleRateSourceStatus(src string, disable bool) error + FiatRateSources() map[string]bool } var _ clientCore = (*core.Core)(nil) @@ -359,6 +361,7 @@ func New(cfg *Config) (*WebServer, error) { apiAuth.Post("/updatecert", s.apiUpdateCert) apiAuth.Post("/updatedexhost", s.apiUpdateDEXHost) apiAuth.Post("/restorewalletinfo", s.apiRestoreWalletInfo) + apiAuth.Post("/toggleratesource", s.apiToggleRateSource) }) }) diff --git a/client/webserver/webserver_test.go b/client/webserver/webserver_test.go index aa955d5b25..37729d2aa4 100644 --- a/client/webserver/webserver_test.go +++ b/client/webserver/webserver_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -69,6 +70,7 @@ type TCore struct { notHas bool notRunning bool notOpen bool + rateSourceErr error } func (c *TCore) Network() dex.Network { return dex.Mainnet } @@ -84,6 +86,13 @@ func (c *TCore) Register(r *core.RegisterForm) (*core.RegisterResult, error) { r func (c *TCore) EstimateRegistrationTxFee(host string, certI interface{}, assetID uint32) (uint64, error) { return 0, nil } +func (c *TCore) ToggleRateSourceStatus(src string, disable bool) error { + return c.rateSourceErr +} +func (c *TCore) FiatRateSources() map[string]bool { + return nil +} + func (c *TCore) InitializeClient(pw, seed []byte) error { return c.initErr } func (c *TCore) Login(pw []byte) (*core.LoginResult, error) { return &core.LoginResult{}, c.loginErr } func (c *TCore) IsInitialized() bool { return c.isInited } @@ -521,7 +530,7 @@ func TestAPIInit(t *testing.T) { tCore.initErr = nil } -// TODO: TesAPIGetDEXInfo +// TODO: TestAPIGetDEXInfo func TestAPINewWallet(t *testing.T) { writer := new(TWriter) @@ -713,3 +722,74 @@ func TestPasswordCache(t *testing.T) { t.Fatal("logout should clear all cached passwords") } } + +func TestAPI_ToggleRatesource(t *testing.T) { + s, tCore, shutdown, err := newTServer(t, false) + if err != nil { + t.Fatalf("error starting server: %v", err) + } + defer shutdown() + writer := new(TWriter) + reader := new(TReader) + + type rateSourceForm struct { + Disable bool `json:"disable"` + Source string `json:"source"` + } + + // Test enabling fiat rate sources. + enableTests := []struct { + name, source, want string + wantErr error + }{{ + name: "Invalid rate source", + source: "binance", + wantErr: errors.New("cannot enable unkown fiat rate source"), + want: `{"ok":false,"msg":"cannot enable unkown fiat rate source"}`, + }, { + name: "ok valid source", + source: "dcrdata", + want: `{"ok":true}`, + }, { + name: "ok already initialized", + source: "dcrdata", + want: `{"ok":true}`, + }} + + for _, test := range enableTests { + body := &rateSourceForm{ + Disable: false, + Source: test.source, + } + tCore.rateSourceErr = test.wantErr + ensureResponse(t, s.apiToggleRateSource, test.want, reader, writer, body, nil) + } + + // Test disabling fiat rate sources. + disableTests := []struct { + name, source, want string + wantErr error + }{{ + name: "Invalid rate source", + source: "binance", + wantErr: errors.New("cannot disable unkown fiat rate source"), + want: `{"ok":false,"msg":"cannot disable unkown fiat rate source"}`, + }, { + name: "ok valid source", + source: "Messari", + want: `{"ok":true}`, + }, { + name: "ok already disabled/not initialized", + source: "Messari", + want: `{"ok":true}`, + }} + + for _, test := range disableTests { + body := &rateSourceForm{ + Disable: true, + Source: test.source, + } + tCore.rateSourceErr = test.wantErr + ensureResponse(t, s.apiToggleRateSource, test.want, reader, writer, body, nil) + } +}