diff --git a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp index 94c3a35b4..45b90f15a 100644 --- a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp +++ b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.cpp @@ -109,10 +109,10 @@ TokenBalances getTokensBalancesForChainIDs(const std::vector& chainIds, } std::vector -getBalanceHistoryOnChain(Accounts::EOAddress account, const std::chrono::seconds& secondsToNow, int sampleCount) +getBalanceHistory(const ChainID& chainID, Accounts::EOAddress account, BalanceHistoryTimeInterval timeInterval) { - std::vector params = {account, secondsToNow.count(), sampleCount}; - json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistoryOnChain"}, {"params", params}}; + std::vector params = {chainID, account, timeInterval}; + json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistory"}, {"params", params}}; auto result = Utils::statusGoCallPrivateRPC(inputJson.dump().c_str()); const auto resultJson = json::parse(result); diff --git a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h index 5a671a603..780df3513 100644 --- a/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h +++ b/libs/StatusGoQt/src/StatusGo/Wallet/WalletApi.h @@ -70,14 +70,24 @@ struct TokenBalanceHistory NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(TokenBalanceHistory, value, time) +/// @see status-go's services/wallet/transfer/controller.go BalanceHistoryTimeInterval +enum BalanceHistoryTimeInterval +{ + BalanceHistory7Hours = 1, + BalanceHistory1Month, + BalanceHistory6Months, + BalanceHistory1Year, + BalanceHistoryAllTime +}; + /// \warning it relies on the stored transaction data fetched by calling \c checkRecentHistory /// \todo reconsider \c checkRecentHistory dependency /// /// \see checkRecentHistory -/// \note status-go's API -> GetBalanceHistoryOnChain@api.go +/// \note status-go's API -> GetBalanceHistory@api.go /// \throws \c CallPrivateRpcError std::vector -getBalanceHistoryOnChain(Accounts::EOAddress account, const std::chrono::seconds& secondsToNow, int sampleCount); +getBalanceHistory(const ChainID& chainID, Accounts::EOAddress account, BalanceHistoryTimeInterval timeInterval); /// \note status-go's API -> CheckRecentHistory@api.go /// \throws \c CallPrivateRpcError diff --git a/libs/StatusQ/qml/Status/Core/Theme/Theme.qml b/libs/StatusQ/qml/Status/Core/Theme/Theme.qml index d3afc8c36..34c65265e 100644 --- a/libs/StatusQ/qml/Status/Core/Theme/Theme.qml +++ b/libs/StatusQ/qml/Status/Core/Theme/Theme.qml @@ -31,7 +31,7 @@ QtObject { current = isCurrentSystemThemeDark? darkTheme : lightTheme; break; default: - console.warning('Unknown theme. Valid themes are "light" and "dark"') + console.warn('Unknown theme. Valid themes are "light" and "dark"') } } } diff --git a/src/app/modules/main/wallet_section/all_tokens/controller.nim b/src/app/modules/main/wallet_section/all_tokens/controller.nim index 69fd44fe9..a42b2916a 100644 --- a/src/app/modules/main/wallet_section/all_tokens/controller.nim +++ b/src/app/modules/main/wallet_section/all_tokens/controller.nim @@ -40,6 +40,10 @@ proc init*(self: Controller) = let args = TokenHistoricalDataArgs(e) self.delegate.tokenHistoricalDataResolved(args.result) + self.events.on(SIGNAL_BALANCE_HISTORY_DATA_READY) do(e:Args): + let args = TokenBalanceHistoryDataArgs(e) + self.delegate.tokenBalanceHistoryDataResolved(args.result) + proc getTokens*(self: Controller): seq[token_service.TokenDto] = proc compare(x, y: token_service.TokenDto): int = if x.name < y.name: @@ -73,3 +77,5 @@ method findTokenSymbolByAddress*(self: Controller, address: string): string = method getHistoricalDataForToken*(self: Controller, symbol: string, currency: string, range: int) = self.tokenService.getHistoricalDataForToken(symbol, currency, range) +method fetchHistoricalBalanceForTokenAsJson*(self: Controller, address: string, symbol: string, timeIntervalEnum: int) = + self.tokenService.fetchHistoricalBalanceForTokenAsJson(address, symbol, BalanceHistoryTimeInterval(timeIntervalEnum)) \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/all_tokens/historical_balance.md b/src/app/modules/main/wallet_section/all_tokens/historical_balance.md deleted file mode 100644 index 9244ee22e..000000000 --- a/src/app/modules/main/wallet_section/all_tokens/historical_balance.md +++ /dev/null @@ -1,153 +0,0 @@ -# Implement historical balance - -Task [#7662](https://github.com/status-im/status-desktop/issues/7662) - -## Summary - -User story: as a user I want to see the historical balance of a specific token in the Asset view Balance tab - -UI design [Figma](https://www.figma.com/file/FkFClTCYKf83RJWoifWgoX/Wallet-v2?node-id=6770%3A76490) - -## Considerations - -Technical requirements - -- New UI View - - Time interval tabs - - [ ] Save last selected tab? Which is the default? - - The price history uses All Time as default - - Show the graph for the selected tab -- New Controller fetch API entry and async Response -- `status-go` API call to fetch token historical balance - -### Assumptions - -Data source - -- The balance history is unique for each address and token. -- It is represented by the blockchain transactions (in/out) for a specific address and token -- The original data resides in blocks on blockchain as transactions -- Direct data fetch is not possible so using infura APIs that caches the data is the alternative -- We already fetch part of the data when displaying `Activity` as a list of transactions -- Constructing the data forward (from the block 0) is not practical. - - Given that we have to show increasing period of activity starting from 1H to All time best is to fetch the data backwards - - Start going from current balance to the past and inverse the operations while going back - - Q - - [ ] What information do we get with each transaction request? - -Caching the data - -- It is a requirement to reduce the number of requests and improve future performance -- Q - - [ ] How do we match the data points not to duplicate? - - [ ] What unique key should we use as primary key in the `balance_cache` DB? - - How about `chainID` + `address` + `token`? - - [x] Is our sqlite DB the best time series data store we have at hand? - - Yes, great integration - - [x] Do we adjust the data points later on or just add forever? - - For start we just add. We might prune old data later on based on age and granularity - - [ ] What do we already cache with transaction history API? - - [x] What granularity do we cache? - - All we fetch for now. Can't see yet how to manage gaps beside the current merging blocks implementation - - [x] What is the cache eviction policy? - - None yet, always growing. Can't see concerns of cache size yet due to scarce transactions for regular users - - Can be done later as an optimization - -View - -- Q - - [ ] How do we represent data? Each transaction is a point and interpolate in between? - - [x] Are "Overview" and "Activity" tabs affected somehow? - - It seems not, quite isolated and no plan to update transactions for now. Can be done later as an optimization - -### Dependencies - -Infura API - -### Constraints - -- Data points granularity should be optimal - - In theory it doesn't make sense to show more than 1 data point per pixel - - In practice it make no sense to show more than 1 data point per few pixels - - Q - - [ ] What is the optimal granularity? Time range based? - - [ ] How do we prune the data points to match the optimal granularity? Do we need this in practice? Can we just show all the data points? - - [ ] How about having 10 transactions in a day and showing the ALL time plot -- Q - - [ ] What is the interval for Showing All Time case? Starting from the first transaction? Or from the first block? - - It seems the current price history start from 2010 - -## Development - -Data scattered in `transfers` DB table and `balanceCache` - -Q: - -- [ ] Unify it, update? Is there a relation in between these two sources? -- [ ] How to fill the gaps? - -### Reference - -Token historical price PRs - -- status-desktop [#7599](https://github.com/status-im/status-desktop/pull/7599/files) - - See `TokenMarketValuesStore` for converting from raw data to QML friendly JS data: `ui/imports/shared/stores/TokenMarketValuesStore.qml` -- status-go [#2882](https://github.com/status-im/status-go/pull/2882/files) - -Building blocks - -- status-go - - Transaction history, common data: `CheckRecentHistory` - `services/wallet/transfer/controller.go` - - setup a running task that fetches balances for chainIDs and accounts - - caches balances in memory see `balanceCache` - - Q - - [x] Queries block ranges? - - Retrieve all "old" block ranges (from block - to block) in table `blocks_ranges` to match the specified network and address. Also order by block from - - See `BlocksRange {from, to *big.Int}` - `services/wallet/transfer/block.go` - - Merge them in continuous blocks simplifying the ranges - - See `TestGetNewRanges` - `services/wallet/transfer/block_test.go` - - Deletes the simplified and add new ranges to the table - - [x] When are block ranges fragmented? - - `setInitialBlocksRange` for all the accounts and watched addresses added initially from block 0 to latest - `services/wallet/transfer/block.go` - - Q: - - [ ] What does latest means? Latest block in the chain? - - `eth_getBlockByNumber` in `HeaderByNumber` - `services/wallet/chain/client.go` - - Event `EventFetchingRecentHistory` processes the history and updates the block ranges via `ProcessBlocks`-> `upsertRange` - `services/wallet/transfer/commands.go` - - [x] Reactor loops? - - Reactor listens to new blocks and stores transfers into the database. - - `ERC20TransfersDownloader`, `ETHDownloader` - - Updates **in memory cache of balances** maps (an address to a map of a block number and the balance of this particular address) - - `balanceCache` - `services/wallet/transfer/balance_cache.go` - - [x] Why `watchAccountsChanges`? - - To update the reactor when list of accounts is updated (added/removed) - - [ ] How do we identify a balance cache entry? -- NIM -- QML - - `RootStore` - `ui/imports/shared/stores/RootStore.qml` - - Entry point for accessing underlying NIM model - - `RightTabView` - `ui/app/AppLayouts/Wallet/views/RightTabView.qml` - - Contains the "Asset" tab from which user selects the token to see history for - - StatusQ's `StatusChartPanel` - `ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml` - - `Chart` - `ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml` - - Canvas based drawing using `Chart.js` - - `HistoryView` calls `RootStore.getTransfersByAddress` to retrieve list of transfers which are delivered as async signals - - `getTransfersByAddress` will call `status-go`'s `GetTransfersByAddress` which will update `blocks` and `transfers` DB tables - - Q: - - [ ] How is this overlapping with the check recent history? - -### TODO - -- [x] New `status-go` API to fetch balance history from existing cache using rules (granularity, time range) - - ~~In the meantime use fetch transactions to populate the cache for testing purposes~~ - - This doesn't work because of the internal transactions that don't show up in the transaction history -- [ ] Sample blocks using `eth_getBalance` -- [ ] Extend Controller to Expose balance history API -- [ ] Implement UI View to use the new API and display the graph of existing data -- [ ] Extend `status-go` - - [ ] Control fetching of new balances for history purpose - - [ ] Premature optimization? - - DB cache eviction policy - - DB cache granularity control -- [ ] Add balance cache DB and sync it with in memory cache - - [ ] Retrieve from the DB if missing exists optimization -- [ ] Extend UI View with controls for time range \ No newline at end of file diff --git a/src/app/modules/main/wallet_section/all_tokens/io_interface.nim b/src/app/modules/main/wallet_section/all_tokens/io_interface.nim index e4cd3335a..0eb818a5c 100644 --- a/src/app/modules/main/wallet_section/all_tokens/io_interface.nim +++ b/src/app/modules/main/wallet_section/all_tokens/io_interface.nim @@ -38,6 +38,12 @@ method getHistoricalDataForToken*(self: AccessInterface, symbol: string, currenc method tokenHistoricalDataResolved*(self: AccessInterface, tokenDetails: string) {.base.} = raise newException(ValueError, "No implementation available") +method fetchHistoricalBalanceForTokenAsJson*(self: AccessInterface, address: string, symbol: string, timeIntervalEnum: int) {.base.} = + raise newException(ValueError, "No implementation available") + +method tokenBalanceHistoryDataResolved*(self: AccessInterface, balanceHistoryJson: string) {.base.} = + raise newException(ValueError, "No implementation available") + # View Delegate Interface # Delegate for the view must be declared here due to use of QtObject and multi # inheritance, which is not well supported in Nim. diff --git a/src/app/modules/main/wallet_section/all_tokens/module.nim b/src/app/modules/main/wallet_section/all_tokens/module.nim index b82e1488e..fa4439719 100644 --- a/src/app/modules/main/wallet_section/all_tokens/module.nim +++ b/src/app/modules/main/wallet_section/all_tokens/module.nim @@ -102,3 +102,10 @@ method getHistoricalDataForToken*(self: Module, symbol: string, currency: string method tokenHistoricalDataResolved*(self: Module, tokenDetails: string) = self.view.tokenHistoricalDataReady(tokenDetails) + + +method fetchHistoricalBalanceForTokenAsJson*(self: Module, address: string, symbol: string, timeIntervalEnum: int) = + self.controller.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + +method tokenBalanceHistoryDataResolved*(self: Module, balanceHistoryJson: string) = + self.view.tokenBalanceHistoryDataReady(balanceHistoryJson) diff --git a/src/app/modules/main/wallet_section/all_tokens/view.nim b/src/app/modules/main/wallet_section/all_tokens/view.nim index 6baaf5dc1..060f72bd0 100644 --- a/src/app/modules/main/wallet_section/all_tokens/view.nim +++ b/src/app/modules/main/wallet_section/all_tokens/view.nim @@ -82,3 +82,8 @@ QtObject: self.delegate.getHistoricalDataForToken(symbol, currency) proc tokenHistoricalDataReady*(self: View, tokenDetails: string) {.signal.} + + proc fetchHistoricalBalanceForTokenAsJson*(self: View, address: string, symbol: string, timeIntervalEnum: int) {.slot.} = + self.delegate.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + + proc tokenBalanceHistoryDataReady*(self: View, balanceHistoryJson: string) {.signal.} \ No newline at end of file diff --git a/src/app_service/service/token/async_tasks.nim b/src/app_service/service/token/async_tasks.nim index 369895f0f..7f074e98d 100644 --- a/src/app_service/service/token/async_tasks.nim +++ b/src/app_service/service/token/async_tasks.nim @@ -85,3 +85,44 @@ const getTokenHistoricalDataTask*: Task = proc(argEncoded: string) {.gcsafe, nim } arg.finish(output) +type + BalanceHistoryTimeInterval* {.pure.} = enum + BalanceHistory7Hours = 0, + BalanceHistory1Month, + BalanceHistory6Months, + BalanceHistory1Year, + BalanceHistoryAllTime + +type + GetTokenBalanceHistoryDataTaskArg = ref object of QObjectTaskArg + chainId: int + address: string + symbol: string + timeInterval: BalanceHistoryTimeInterval + +const getTokenBalanceHistoryDataTask*: Task = proc(argEncoded: string) {.gcsafe, nimcall.} = + let arg = decode[GetTokenBalanceHistoryDataTaskArg](argEncoded) + var response = %*{} + try: + # status-go time intervals are starting from 1 + response = backend.getBalanceHistory(arg.chainId, arg.address, int(arg.timeInterval) + 1).result + + let output = %* { + "chainId": arg.chainId, + "address": arg.address, + "symbol": arg.symbol, + "timeInterval": int(arg.timeInterval), + "historicalData": response + } + + arg.finish(output) + return + except Exception as e: + let output = %* { + "chainId": arg.chainId, + "address": arg.address, + "symbol": arg.symbol, + "timeInterval": int(arg.timeInterval), + "error": "Balance history value not found", + } + arg.finish(output) \ No newline at end of file diff --git a/src/app_service/service/token/service.nim b/src/app_service/service/token/service.nim index dff5f6fd9..913afa2d3 100644 --- a/src/app_service/service/token/service.nim +++ b/src/app_service/service/token/service.nim @@ -22,6 +22,7 @@ include async_tasks const SIGNAL_TOKEN_DETAILS_LOADED* = "tokenDetailsLoaded" const SIGNAL_TOKEN_LIST_RELOADED* = "tokenListReloaded" const SIGNAL_TOKEN_HISTORICAL_DATA_LOADED* = "tokenHistoricalDataLoaded" +const SIGNAL_BALANCE_HISTORY_DATA_READY* = "tokenBalanceHistoryDataReady" type TokenDetailsLoadedArgs* = ref object of Args @@ -43,6 +44,10 @@ type TokenHistoricalDataArgs* = ref object of Args result*: string +type + TokenBalanceHistoryDataArgs* = ref object of Args + result*: string + QtObject: type Service* = ref object of QObject events: EventEmitter @@ -198,7 +203,7 @@ QtObject: ) self.threadpool.start(arg) - proc tokenHistorticalDataResolved*(self: Service, response: string) {.slot.} = + proc tokenHistoricalDataResolved*(self: Service, response: string) {.slot.} = let responseObj = response.parseJson if (responseObj.kind != JObject): info "prepared tokens are not a json object" @@ -208,13 +213,41 @@ QtObject: result: response )) + proc tokenBalanceHistoryDataResolved*(self: Service, response: string) {.slot.} = + # TODO + let responseObj = response.parseJson + if (responseObj.kind != JObject): + info "blance history response is not a json object" + return + + self.events.emit(SIGNAL_BALANCE_HISTORY_DATA_READY, TokenBalanceHistoryDataArgs( + result: response + )) + proc getHistoricalDataForToken*(self: Service, symbol: string, currency: string, range: int) = let arg = GetTokenHistoricalDataTaskArg( tptr: cast[ByteAddress](getTokenHistoricalDataTask), vptr: cast[ByteAddress](self.vptr), - slot: "tokenHistorticalDataResolved", + slot: "tokenHistoricalDataResolved", symbol: symbol, currency: currency, range: range ) self.threadpool.start(arg) + + proc fetchHistoricalBalanceForTokenAsJson*(self: Service, address: string, symbol: string, timeInterval: BalanceHistoryTimeInterval) = + let networks = self.networkService.getNetworks() + for network in networks: + if network.enabled and network.nativeCurrencySymbol == symbol: + let arg = GetTokenBalanceHistoryDataTaskArg( + tptr: cast[ByteAddress](getTokenBalanceHistoryDataTask), + vptr: cast[ByteAddress](self.vptr), + slot: "tokenBalanceHistoryDataResolved", + chainId: network.chainId, + address: address, + symbol: symbol, + timeInterval: timeInterval + ) + self.threadpool.start(arg) + return + error "faild to find a network with the symbol", symbol \ No newline at end of file diff --git a/src/backend/backend.nim b/src/backend/backend.nim index 7cd169fbc..9cfc8f5d1 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -281,4 +281,9 @@ rpc(getDailyMarketValues, "wallet"): rpc(getName, "ens"): chainId: int - address: string \ No newline at end of file + address: string + +rpc(getBalanceHistory, "wallet"): + chainId: int + address: string + timeInterval: int \ No newline at end of file diff --git a/test/libs/StatusGoQt/test_wallet.cpp b/test/libs/StatusGoQt/test_wallet.cpp index f893ca59b..9b460f1b6 100644 --- a/test/libs/StatusGoQt/test_wallet.cpp +++ b/test/libs/StatusGoQt/test_wallet.cpp @@ -353,7 +353,7 @@ TEST(WalletApi, TestCheckRecentHistory) } // TODO: this is a debugging test. Augment it with local Ganache environment to have a reliable test -TEST(WalletApi, TestGetBalanceHistoryOnChain) +TEST(WalletApi, TestGetBalanceHistory) { ScopedTestAccount testAccount(test_info_->name()); @@ -366,6 +366,15 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) const auto updatedAccounts = Accounts::getAccounts(); ASSERT_EQ(updatedAccounts.size(), 3); + auto networks = Wallet::getEthereumChains(false); + ASSERT_GT(networks.size(), 0); + auto mainNetIt = + std::find_if(networks.begin(), networks.end(), [](const auto& n) { return n.chainName == "Mainnet"; }); + ASSERT_NE(mainNetIt, networks.end()); + const auto& mainNet = *mainNetIt; + + auto tokens = Wallet::getTokens(mainNet.chainId); + const auto newAccountIt = std::find_if(updatedAccounts.begin(), updatedAccounts.end(), [newTestAccountName](const auto& a) { return a.name == newTestAccountName; @@ -373,18 +382,24 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) ASSERT_NE(newAccountIt, updatedAccounts.end()); const auto& newAccount = *newAccountIt; - auto testIntervals = {std::chrono::round(1h), - std::chrono::round(std::chrono::days(1)), - std::chrono::round(std::chrono::days(7)), - std::chrono::round(std::chrono::months(1)), - std::chrono::round(std::chrono::months(6)), - std::chrono::round(std::chrono::years(1)), - std::chrono::round(std::chrono::years(100))}; - auto sampleCount = 10; - for(const auto& historyDuration : testIntervals) + auto testIntervals = {Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours, + Wallet::BalanceHistoryTimeInterval::BalanceHistory1Month, + Wallet::BalanceHistoryTimeInterval::BalanceHistory6Months, + Wallet::BalanceHistoryTimeInterval::BalanceHistory1Year, + Wallet::BalanceHistoryTimeInterval::BalanceHistoryAllTime}; + + std::map testIntervalsStrs{ + {Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours, "7H"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory1Month, "1M"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory6Months, "6M"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistory1Year, "1Y"}, + {Wallet::BalanceHistoryTimeInterval::BalanceHistoryAllTime, "All"}}; + + for(const auto& historyInterval : testIntervals) { - auto balanceHistory = Wallet::getBalanceHistoryOnChain(newAccount.address, historyDuration, sampleCount); - ASSERT_TRUE(balanceHistory.size() > 0); // TODO: we get one extra, match sample size + // TODO: next `mainNet.nativeCurrencySymbol`, later `tokens.symbol` + auto balanceHistory = Wallet::getBalanceHistory(mainNet.chainId, newAccount.address, historyInterval); + ASSERT_TRUE(balanceHistory.size() > 0); auto weiToEth = [](const StatusGo::Wallet::BigInt& wei) -> double { StatusGo::Wallet::BigInt q; // wei / eth @@ -398,19 +413,18 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain) return q.convert_to() + (qSzabos.convert_to() / ((weiD / szaboD).convert_to())); }; - QFile file(QString("/tmp/balance_history-%1s.csv").arg(historyDuration.count())); + QFile file(QString("/tmp/balance_history-%s.csv").arg(testIntervalsStrs[historyInterval])); if(file.open(QIODevice::WriteOnly | QIODevice::Text)) { QTextStream out(&file); out << "Balance, Timestamp" << Qt::endl; - for(int i = balanceHistory.size() - 1; i >= 0; --i) + for(int i = 0; i < balanceHistory.size(); ++i) { out << weiToEth(balanceHistory[i].value) << "," << balanceHistory[i].time.toSecsSinceEpoch() << Qt::endl; } } file.close(); - sampleCount += 10; } } diff --git a/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml b/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml index 9f019ba57..605ca5608 100644 --- a/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml +++ b/ui/StatusQ/sandbox/pages/StatusChartPanelPage.qml @@ -18,7 +18,7 @@ Item { property real minStep: 12000 property real maxStep: 22000 - property var graphTabsModel: [{text: "Price", enabled: true}, {text: "Balance", enabled: false}] + property var graphTabsModel: [{text: "Price", enabled: true}, {text: "Balance", enabled: true}] property var timeRangeTabsModel: [{text: "1H", enabled: true}, {text: "1D", enabled: true},{text: "7D", enabled: true}, {text: "1M", enabled: true}, {text: "6M", enabled: true}, diff --git a/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml b/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml index 41f2c2c55..110253ca8 100644 --- a/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml +++ b/ui/StatusQ/src/StatusQ/Components/StatusChartPanel.qml @@ -58,6 +58,11 @@ Page { /*! \qmlproperty var StatusChartPanel::graphsModel This property holds the graphs model options to be set on the left side tab bar of the header. + The JS array entries are expected to be objects with the following properties: + - text (string): The text to be displayed on the tab + - enabled (bool): Whether the tab is enabled or not + - isTimeRange (bool): Whether the tab is a time range tab or graph type tab + - privateIdentifier (string): An optional unique identifier for the tab that will be received \c headerTabClicked signal. Otherwise, the text will be used. */ property var graphsModel /*! @@ -81,8 +86,10 @@ Page { /*! \qmlproperty string StatusChartPanel::selectedTimeRange This property holds holds the text of the current time range tab bar selected tab. + + \todo no need for undefined state, make tab models declaratively */ - property string selectedTimeRange: timeRangeTabBar.currentItem.text + property string selectedTimeRange: timeRangeTabBar.currentItem ? timeRangeTabBar.currentItem.text : "" /*! \qmlproperty string StatusChartPanel::defaultTimeRangeIndexShown @@ -94,15 +101,18 @@ Page { \qmlsignal This signal is emitted when a header tab bar is clicked. */ - signal headerTabClicked(string text) + signal headerTabClicked(var privateIdentifier, bool isTimeRange) Component { id: tabButton StatusTabButton { + property var privateIdentifier: null + property bool isTimeRange: false + leftPadding: 0 width: implicitWidth onClicked: { - root.headerTabClicked(text); + root.headerTabClicked(privateIdentifier, isTimeRange); } } } @@ -111,7 +121,9 @@ Page { if (!!timeRangeModel) { for (var i = 0; i < timeRangeModel.length; i++) { var timeTab = tabButton.createObject(root, { text: timeRangeModel[i].text, - enabled: timeRangeModel[i].enabled }); + enabled: timeRangeModel[i].enabled, + isTimeRange: true, + privateIdentifier: timeRangeModel[i].text }); timeRangeTabBar.addItem(timeTab); } timeRangeTabBar.currentIndex = defaultTimeRangeIndexShown @@ -119,7 +131,9 @@ Page { if (!!graphsModel) { for (var j = 0; j < graphsModel.length; j++) { var graphTab = tabButton.createObject(root, { text: graphsModel[j].text, - enabled: graphsModel[j].enabled }); + enabled: graphsModel[j].enabled, + isTimeRange: false, + privateIdentifier: typeof graphsModel[j].id !== "undefined" ? graphsModel[j].id : null}); graphsTabBar.addItem(graphTab); } } diff --git a/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml b/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml index 23fc7ec6f..29bc1440d 100644 --- a/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml +++ b/ui/StatusQ/src/StatusQ/Components/private/chart/Chart.qml @@ -24,6 +24,13 @@ Canvas { signal animationFinished() + + function updateToNewData() + { + jsChart.update('none'); + root.requestPaint(); + } + function animateToNewData() { chartAnimationProgress = 0.1; diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml index a57e3a14d..5be872c65 100644 --- a/ui/app/AppLayouts/Wallet/WalletLayout.qml +++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml @@ -59,7 +59,6 @@ Item { } } - StatusSectionLayout { anchors.top: seedPhraseWarning.bottom height: root.height - seedPhraseWarning.height diff --git a/ui/app/AppLayouts/Wallet/views/RightTabView.qml b/ui/app/AppLayouts/Wallet/views/RightTabView.qml index c90853f16..975f24dfc 100644 --- a/ui/app/AppLayouts/Wallet/views/RightTabView.qml +++ b/ui/app/AppLayouts/Wallet/views/RightTabView.qml @@ -117,9 +117,12 @@ Item { } AssetsDetailView { id: assetDetailView + Layout.fillWidth: true Layout.fillHeight: true visible: (stack.currentIndex === 2) + + address: RootStore.currentAccount.mixedcaseAddress } TransactionDetailView { id: transactionDetailView diff --git a/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml b/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml index d0b6c88fa..c15e7d52c 100644 --- a/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml +++ b/ui/imports/shared/popups/keycard/helpers/KeycardItem.qml @@ -96,12 +96,12 @@ StatusListItem { tagsModel.clear() if (root.keyPairAccounts === "") { // should never be here, as it's not possible to have keypair item without at least a single account - console.warning("accounts list is empty for selecting keycard pair") + console.warn("accounts list is empty for selecting keycard pair") return } let obj = JSON.parse(root.keyPairAccounts) if (obj.error) { - console.warning("error parsing accounts for selecting keycard pair, error: ", obj.error) + console.warn("error parsing accounts for selecting keycard pair, error: ", obj.error) return } diff --git a/ui/imports/shared/stores/ChartStoreBase.qml b/ui/imports/shared/stores/ChartStoreBase.qml new file mode 100644 index 000000000..d9a6ee305 --- /dev/null +++ b/ui/imports/shared/stores/ChartStoreBase.qml @@ -0,0 +1,126 @@ +import QtQuick 2.13 + +import utils 1.0 + +Item { + id: root + + // @see src/app_service/service/token/async_tasks.nim BalanceHistoryTimeInterval + enum TimeRange { + Weekly = 0, + Monthly, + HalfYearly, + Yearly, + All + } + + readonly property var timeRangeTabsModel: [ + {text: qsTr("7D"), enabled: true, timeRange: ChartStoreBase.TimeRange.Weekly}, + {text: qsTr("1M"), enabled: true, timeRange: ChartStoreBase.TimeRange.Monthly}, + {text: qsTr("6M"), enabled: true, timeRange: ChartStoreBase.TimeRange.HalfYearly}, + {text: qsTr("1Y"), enabled: true, timeRange: ChartStoreBase.TimeRange.Yearly}, + {text: qsTr("ALL"), enabled: true, timeRange: ChartStoreBase.TimeRange.All}] + + property var weeklyData: [] + property var monthlyData: [] + property var halfYearlyData: [] + property var yearlyData: [] + property var allData: [] + + property var weeklyTimeRange: [] + property var monthlyTimeRange: [] + property var halfYearlyTimeRange: [] + property var yearlyTimeRange: [] + property var allTimeRange: [] + + property int monthlyMaxTicks: monthlyTimeRange.length/d.hoursInADay + property int weeklyMaxTicks: weeklyTimeRange.length/d.hoursInADay + property int halfYearlyMaxTicks: halfYearlyTimeRange.length/d.avgLengthOfMonth + property int yearlyMaxTicks: yearlyTimeRange.length/d.avgLengthOfMonth + property int allTimeRangeTicks: 0 + + // BEWARE that timeRange, dataRange and maxTicks properties are coupled with the timeRangeTabsModel order through + // indexing. See StatusChartPanel.timeRangeTabBarIndex + readonly property var timeRange: [ + {'7D': weeklyTimeRange}, + {'1M': monthlyTimeRange}, + {'6M': halfYearlyTimeRange}, + {'1Y': yearlyTimeRange}, + {'ALL': allTimeRange} + ] + + readonly property var dataRange: [ + {'7D': weeklyData}, + {'1M': monthlyData}, + {'6M': halfYearlyData}, + {'1Y': yearlyData}, + {'ALL': allData} + ] + + readonly property var maxTicks: [ + {'7D': weeklyMaxTicks}, + {'1M': monthlyMaxTicks}, + {'6M': halfYearlyMaxTicks}, + {'1Y': yearlyMaxTicks}, + {'ALL': allTimeRangeTicks} + ] + + /// \timeRange is the time range of the data that was updated + signal newDataReady(int timeRange) + + function timeRangeEnumToStr(enumVal) { + return d.timeRangeTabsModel.get(enumVal) + } + function timeRangeStrToEnum(str) { + return d.timeRangeStrToEnumMap.get(str) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function updateRequestTime(timeRange) { + d.requestTimes.set(timeRange, new Date()) + } + + function resetRequestTime() { + d.requestTimes.set(timeRange, new Date(0)) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function isTimeToRequest(timeRange) { + if(d.requestTimes.has(timeRange)) { + const hoursToIgnore = 12 + let existing = d.requestTimes.get(timeRange) + let willBeMs = new Date(existing.getTime() + (hoursToIgnore * 3600000)) + return new Date(willBeMs) < new Date() + } + else + return true + } + + QtObject { + id: d + + readonly property int hoursInADay: 24 + readonly property int avgLengthOfMonth: 30 + property var timeRangeEnumToStrMap: null + property var timeRangeStrToEnumMap: null + property var requestTimes: null + } + + Component.onCompleted: { + if(d.timeRangeEnumToStrMap === null) { + d.timeRangeEnumToStrMap = new Map() + for (const x of timeRangeTabsModel) { + d.timeRangeEnumToStrMap.set(x.timeRange, x.text) + } + d.timeRangeStrToEnumMap = new Map() + for (const x of d.timeRangeEnumToStrMap.entries()) { + let key = x[0] + let val = x[1] + d.timeRangeStrToEnumMap.set(val, key) + } + } + if(d.requestTimes === null) { + d.requestTimes = new Map() + } + } +} \ No newline at end of file diff --git a/ui/imports/shared/stores/RootStore.qml b/ui/imports/shared/stores/RootStore.qml index 3a0db36c7..f460ae4c0 100644 --- a/ui/imports/shared/stores/RootStore.qml +++ b/ui/imports/shared/stores/RootStore.qml @@ -204,4 +204,9 @@ QtObject { function getHistoricalDataForToken(symbol, currency) { walletSectionAllTokens.getHistoricalDataForToken(symbol,currency) } + + // TODO: range until we optimize to cache the data and abuse the requests + function fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) { + walletSectionAllTokens.fetchHistoricalBalanceForTokenAsJson(address, symbol, timeIntervalEnum) + } } diff --git a/ui/imports/shared/stores/TokenBalanceHistoryStore.qml b/ui/imports/shared/stores/TokenBalanceHistoryStore.qml new file mode 100644 index 000000000..2151a8b13 --- /dev/null +++ b/ui/imports/shared/stores/TokenBalanceHistoryStore.qml @@ -0,0 +1,89 @@ +import QtQuick 2.13 + +import utils 1.0 + +ChartStoreBase { + id: root + + /*required*/ property string address: "" + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function setData(timeRange, timeRangeData, balanceData) { + switch(timeRange) { + case ChartStoreBase.TimeRange.Weekly: + root.weeklyData = balanceData + root.weeklyTimeRange = timeRangeData + root.weeklyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.Monthly: + root.monthlyData = balanceData + root.monthlyTimeRange = timeRangeData + root.monthlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.HalfYearly: + root.halfYearlyData = balanceData + root.halfYearlyTimeRange = timeRangeData + root.halfYearlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.Yearly: + root.yearlyData = balanceData + root.yearlyTimeRange = timeRangeData + root.yearlyMaxTicks = 0 + break; + case ChartStoreBase.TimeRange.All: + root.allData = balanceData + root.allTimeRange = timeRangeData + root.allTimeRangeTicks = 0 + break; + default: + console.warn("Invalid or unsupported time range") + break; + } + root.newDataReady(timeRange) + } + + /// \arg timeRange: of type ChartStoreBase.TimeRange + function resetData(timeRange) { + root.setData(timeRange, [], []) + } + + Connections { + target: walletSectionAllTokens + onTokenBalanceHistoryDataReady: (balanceHistory) => { + // chainId, address, symbol, timeInterval + let response = JSON.parse(balanceHistory) + if (response === null) { + console.warn("error parsing balance history json message data") + root.resetRequestTime() + return + } + + if(typeof response.historicalData === "undefined" || response.historicalData === null || response.historicalData.length == 0) { + console.warn("error no data in balance history. Must be an error from status-go") + root.resetRequestTime() + return + } else if(response.address !== root.address) { + // Ignore data for other addresses. Will be handled by other instances of this store + return + } + + root.resetData(response.timeInterval) + + var tmpTimeRange = [] + var tmpDataValues = [] + for(let i = 0; i < response.historicalData.length; i++) { + let dataEntry = response.historicalData[i] + + let dateString = response.timeInterval == ChartStoreBase.TimeRange.Weekly || response.timeInterval == ChartStoreBase.TimeRange.Monthly + ? Utils.getDayMonth(dataEntry.time * 1000, RootStore.accountSensitiveSettings.is24hTimeFormat) + : Utils.getMonthYear(dataEntry.time * 1000) + tmpTimeRange.push(dateString) + + tmpDataValues.push(parseFloat(globalUtils.wei2Eth(dataEntry.value, 18))) + } + + root.setData(response.timeInterval, tmpTimeRange, tmpDataValues) + root.updateRequestTime(response.timeInterval) + } + } +} \ No newline at end of file diff --git a/ui/imports/shared/stores/TokenMarketValuesStore.qml b/ui/imports/shared/stores/TokenMarketValuesStore.qml index 253f1a77c..e6f0ec49d 100644 --- a/ui/imports/shared/stores/TokenMarketValuesStore.qml +++ b/ui/imports/shared/stores/TokenMarketValuesStore.qml @@ -2,97 +2,42 @@ import QtQuick 2.13 import utils 1.0 -QtObject { +ChartStoreBase { id: root - - enum TimeRange { - Weekly = 0, - Monthly, - HalfYearly, - Yearly, - All - } - - readonly property int hoursInADay: 24 - readonly property int avgLengthOfMonth: 30 - - readonly property var graphTabsModel: [{text: qsTr("Price"), enabled: true}, {text: qsTr("Balance"), enabled: false}] - readonly property var timeRangeTabsModel: [{text: qsTr("7D"), enabled: true}, - {text: qsTr("1M"), enabled: true}, {text: qsTr("6M"), enabled: true}, - {text: qsTr("1Y"), enabled: true}, {text: qsTr("ALL"), enabled: true}] - - property var weeklyData - property var monthlyData - property var halfYearlyData - property var yearlyData - property var allData - - property var weeklyTimeRange - property var monthlyTimeRange - property var halfYearlyTimeRange - property var yearlyTimeRange - property var allTimeRange - - readonly property var timeRange: [ - {'7D': weeklyTimeRange}, - {'1M': monthlyTimeRange}, - {'6M': halfYearlyTimeRange}, - {'1Y': yearlyTimeRange}, - {'ALL': allTimeRange} - ] - - readonly property var dataRange: [ - {'7D': weeklyData}, - {'1M': monthlyData}, - {'6M': halfYearlyData}, - {'1Y': yearlyData}, - {'ALL': allData} - ] - - property int allTimeRangeTicks: 0 - - readonly property var maxTicks: [ - {'7D': weeklyTimeRange.length/hoursInADay}, - {'1M': monthlyTimeRange.length/hoursInADay}, - {'6M': halfYearlyTimeRange.length/avgLengthOfMonth}, - {'1Y': yearlyTimeRange.length/avgLengthOfMonth}, - {'ALL': allTimeRangeTicks} - ] - function setTimeAndValueData(data, range) { var marketValues = [] var timeRanges = [] for (var i = 0; i < data.length; ++i) { marketValues[i] = data[i].close; - timeRanges[i] = range === TokenMarketValuesStore.TimeRange.Weekly || range === TokenMarketValuesStore.TimeRange.Monthly ? + timeRanges[i] = range === ChartStoreBase.TimeRange.Weekly || range === ChartStoreBase.TimeRange.Monthly ? Utils.getDayMonth(data[i].time * 1000, RootStore.accountSensitiveSettings.is24hTimeFormat): Utils.getMonthYear(data[i].time * 1000) } switch(range) { - case TokenMarketValuesStore.TimeRange.Weekly: { + case ChartStoreBase.TimeRange.Weekly: { weeklyData = marketValues weeklyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.Monthly: { + case ChartStoreBase.TimeRange.Monthly: { monthlyData = marketValues monthlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.HalfYearly: { + case ChartStoreBase.TimeRange.HalfYearly: { halfYearlyData = marketValues halfYearlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.Yearly: { + case ChartStoreBase.TimeRange.Yearly: { yearlyData = marketValues yearlyTimeRange = timeRanges break } - case TokenMarketValuesStore.TimeRange.All: { + case ChartStoreBase.TimeRange.All: { allData = marketValues allTimeRange = timeRanges if(data.length > 0) diff --git a/ui/imports/shared/views/AssetsDetailView.qml b/ui/imports/shared/views/AssetsDetailView.qml index d8074c1c6..b5a1e0e3a 100644 --- a/ui/imports/shared/views/AssetsDetailView.qml +++ b/ui/imports/shared/views/AssetsDetailView.qml @@ -14,14 +14,24 @@ import shared.controls 1.0 import "../stores" +/// \beware: heavy shortcuts here, refactor to match the requirements when touching this again +/// \todo split into token history and balance views; they have different requirements that introduce unnecessary complexity +/// \todo take a declarative approach, move logic into the typed backend and remove multiple source of truth (e.g. time ranges) Item { id: root property var token + /*required*/ property string address: "" + + function createStore(address) { + return balanceHistoryComponent.createObject(null, {address: address}) + } QtObject { id: d property var marketValueStore : RootStore.marketValueStore + // TODO: Should be temporary until non native tokens are supported by balance history + property bool isNativeToken: typeof token !== "undefined" && token ? token.symbol === "ETH" : false } Connections { @@ -29,7 +39,7 @@ Item { onTokenHistoricalDataReady: { let response = JSON.parse(tokenDetails) if (response === null) { - console.debug("error parsing message for tokenHistoricalDataReady: error: ", response.error) + console.debug("error parsing json message for tokenHistoricalDataReady") return } if(response.historicalData === null || response.historicalData <= 0) @@ -59,6 +69,11 @@ Item { } } + enum GraphType { + Price = 0, + Balance + } + Loader { id: graphDetailLoader width: parent.width @@ -68,14 +83,66 @@ Item { active: root.visible sourceComponent: StatusChartPanel { id: graphDetail - graphsModel: d.marketValueStore.graphTabsModel - defaultTimeRangeIndexShown: TokenMarketValuesStore.TimeRange.All - timeRangeModel: d.marketValueStore.timeRangeTabsModel - onHeaderTabClicked: chart.animateToNewData() + + property int selectedGraphType: AssetsDetailView.GraphType.Price + property var selectedStore: d.marketValueStore + + function dataReady() { + return typeof selectedStore != "undefined" + } + function timeRangeSelected() { + return dataReady() && graphDetail.timeRangeTabBarIndex >= 0 && graphDetail.selectedTimeRange.length > 0 + } + + readonly property var labelsData: { + return timeRangeSelected() + ? selectedStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : [] + } + readonly property var dataRange: { + return timeRangeSelected() + ? selectedStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : [] + } + readonly property var maxTicksLimit: { + return timeRangeSelected() && typeof selectedStore.maxTicks != "undefined" + ? selectedStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange] + : 0 + } + + graphsModel: [ + {text: qsTr("Price"), enabled: true, id: AssetsDetailView.GraphType.Price}, + { + text: qsTr("Balance"), + enabled: false, // TODO: Enable after adding ECR20 token support and DB cache. Current prototype implementation works only for d.isNativeToken + id: AssetsDetailView.GraphType.Balance + }, + ] + defaultTimeRangeIndexShown: ChartStoreBase.TimeRange.All + timeRangeModel: dataReady() && selectedStore.timeRangeTabsModel + onHeaderTabClicked: (privateIdentifier, isTimeRange) => { + if(!isTimeRange && graphDetail.selectedGraphType !== privateIdentifier) { + graphDetail.selectedGraphType = privateIdentifier + } + + if(graphDetail.selectedGraphType === AssetsDetailView.GraphType.Balance) { + let selectedTimeRangeEnum = balanceStore.timeRangeStrToEnum(graphDetail.selectedTimeRange) + if(balanceStore.isTimeToRequest(selectedTimeRangeEnum)) { + RootStore.fetchHistoricalBalanceForTokenAsJson(root.address, token.symbol, selectedTimeRangeEnum) + balanceStore.updateRequestTime(selectedTimeRangeEnum) + } + } + + if(!isTimeRange) { + graphDetail.selectedStore = graphDetail.selectedGraphType === AssetsDetailView.GraphType.Price ? d.marketValueStore : balanceStore + } + + chart.animateToNewData() + } chart.chartType: 'line' chart.chartData: { return { - labels: d.marketValueStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + labels: graphDetail.labelsData, datasets: [{ xAxisId: 'x-axis-1', yAxisId: 'y-axis-1', @@ -83,7 +150,7 @@ Item { borderColor: (Theme.palette.name === "dark") ? 'rgba(136, 176, 255, 1)' : 'rgba(67, 96, 223, 1)', borderWidth: 3, pointRadius: 0, - data: d.marketValueStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + data: graphDetail.dataRange, parsing: false, }] } @@ -133,7 +200,7 @@ Item { padding: 16, maxRotation: 0, minRotation: 0, - maxTicksLimit: d.marketValueStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange], + maxTicksLimit: graphDetail.maxTicksLimit, }, }], yAxes: [{ @@ -149,6 +216,10 @@ Item { axis.paddingTop = 25; axis.paddingBottom = 0; }, + afterDataLimits: (axis) => { + if(axis.min < 0) + axis.min = 0; + }, ticks: { fontSize: 10, fontColor: (Theme.palette.name === "dark") ? '#909090' : '#939BA1', @@ -161,6 +232,19 @@ Item { } } } + + TokenBalanceHistoryStore { + id: balanceStore + + address: root.address + + onNewDataReady: (timeRange) => { + let selectedTimeRange = timeRangeStrToEnum(graphDetail.selectedTimeRange) + if (timeRange === selectedTimeRange && address === root.address) { + chart.updateToNewData() + } + } + } } } @@ -277,7 +361,7 @@ Item { tagPrimaryLabel.text: qsTr("Website") controlBackground.color: Theme.palette.baseColor2 controlBackground.border.color: "transparent" - visible: token && token.assetWebsiteUrl !== "" + visible: typeof token != "undefined" && token && token.assetWebsiteUrl !== "" MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor @@ -293,7 +377,7 @@ Item { tagSecondaryLabel.text: token && token.smartContractAddress !== "" ? token.smartContractAddress : "---" controlBackground.color: Theme.palette.baseColor2 controlBackground.border.color: "transparent" - visible: token && token.builtOn !== "" && token.smartContractAddress !== "" + visible: typeof token != "undefined" && token && token.builtOn !== "" && token.smartContractAddress !== "" } } }