feat(Wallet) show balance cache for chain native token

Quick integration of fetching balance in the current chart view.

The proper implementation requires refactoring the QML views to separate
price chart, that depends only on the token and chain, from balance that
depends on token, chain and address.

Closes: #7662
This commit is contained in:
Stefan 2022-10-28 20:17:16 +03:00 committed by Stefan Dunca
parent 5450384a34
commit d0389a6305
23 changed files with 504 additions and 258 deletions

View File

@ -109,10 +109,10 @@ TokenBalances getTokensBalancesForChainIDs(const std::vector<ChainID>& chainIds,
}
std::vector<TokenBalanceHistory>
getBalanceHistoryOnChain(Accounts::EOAddress account, const std::chrono::seconds& secondsToNow, int sampleCount)
getBalanceHistory(const ChainID& chainID, Accounts::EOAddress account, BalanceHistoryTimeInterval timeInterval)
{
std::vector<json> params = {account, secondsToNow.count(), sampleCount};
json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistoryOnChain"}, {"params", params}};
std::vector<json> 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);

View File

@ -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<TokenBalanceHistory>
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

View File

@ -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"')
}
}
}

View File

@ -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))

View File

@ -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

View File

@ -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.

View File

@ -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)

View File

@ -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.}

View File

@ -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)

View File

@ -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

View File

@ -282,3 +282,8 @@ rpc(getDailyMarketValues, "wallet"):
rpc(getName, "ens"):
chainId: int
address: string
rpc(getBalanceHistory, "wallet"):
chainId: int
address: string
timeInterval: int

View File

@ -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<std::chrono::seconds>(1h),
std::chrono::round<std::chrono::seconds>(std::chrono::days(1)),
std::chrono::round<std::chrono::seconds>(std::chrono::days(7)),
std::chrono::round<std::chrono::seconds>(std::chrono::months(1)),
std::chrono::round<std::chrono::seconds>(std::chrono::months(6)),
std::chrono::round<std::chrono::seconds>(std::chrono::years(1)),
std::chrono::round<std::chrono::seconds>(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<Wallet::BalanceHistoryTimeInterval, QString> 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<double>() + (qSzabos.convert_to<double>() / ((weiD / szaboD).convert_to<double>()));
};
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;
}
}

View File

@ -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},

View File

@ -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);
}
}

View File

@ -24,6 +24,13 @@ Canvas {
signal animationFinished()
function updateToNewData()
{
jsChart.update('none');
root.requestPaint();
}
function animateToNewData()
{
chartAnimationProgress = 0.1;

View File

@ -59,7 +59,6 @@ Item {
}
}
StatusSectionLayout {
anchors.top: seedPhraseWarning.bottom
height: root.height - seedPhraseWarning.height

View File

@ -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

View File

@ -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
}

View File

@ -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()
}
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}
}

View File

@ -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)

View File

@ -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 !== ""
}
}
}