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:
parent
5450384a34
commit
d0389a6305
|
@ -109,10 +109,10 @@ TokenBalances getTokensBalancesForChainIDs(const std::vector<ChainID>& chainIds,
|
||||||
}
|
}
|
||||||
|
|
||||||
std::vector<TokenBalanceHistory>
|
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};
|
std::vector<json> params = {chainID, account, timeInterval};
|
||||||
json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistoryOnChain"}, {"params", params}};
|
json inputJson = {{"jsonrpc", "2.0"}, {"method", "wallet_getBalanceHistory"}, {"params", params}};
|
||||||
|
|
||||||
auto result = Utils::statusGoCallPrivateRPC(inputJson.dump().c_str());
|
auto result = Utils::statusGoCallPrivateRPC(inputJson.dump().c_str());
|
||||||
const auto resultJson = json::parse(result);
|
const auto resultJson = json::parse(result);
|
||||||
|
|
|
@ -70,14 +70,24 @@ struct TokenBalanceHistory
|
||||||
|
|
||||||
NLOHMANN_DEFINE_TYPE_NON_INTRUSIVE(TokenBalanceHistory, value, time)
|
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
|
/// \warning it relies on the stored transaction data fetched by calling \c checkRecentHistory
|
||||||
/// \todo reconsider \c checkRecentHistory dependency
|
/// \todo reconsider \c checkRecentHistory dependency
|
||||||
///
|
///
|
||||||
/// \see checkRecentHistory
|
/// \see checkRecentHistory
|
||||||
/// \note status-go's API -> GetBalanceHistoryOnChain@api.go
|
/// \note status-go's API -> GetBalanceHistory@api.go
|
||||||
/// \throws \c CallPrivateRpcError
|
/// \throws \c CallPrivateRpcError
|
||||||
std::vector<TokenBalanceHistory>
|
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
|
/// \note status-go's API -> CheckRecentHistory@api.go
|
||||||
/// \throws \c CallPrivateRpcError
|
/// \throws \c CallPrivateRpcError
|
||||||
|
|
|
@ -31,7 +31,7 @@ QtObject {
|
||||||
current = isCurrentSystemThemeDark? darkTheme : lightTheme;
|
current = isCurrentSystemThemeDark? darkTheme : lightTheme;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
console.warning('Unknown theme. Valid themes are "light" and "dark"')
|
console.warn('Unknown theme. Valid themes are "light" and "dark"')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,6 +40,10 @@ proc init*(self: Controller) =
|
||||||
let args = TokenHistoricalDataArgs(e)
|
let args = TokenHistoricalDataArgs(e)
|
||||||
self.delegate.tokenHistoricalDataResolved(args.result)
|
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 getTokens*(self: Controller): seq[token_service.TokenDto] =
|
||||||
proc compare(x, y: token_service.TokenDto): int =
|
proc compare(x, y: token_service.TokenDto): int =
|
||||||
if x.name < y.name:
|
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) =
|
method getHistoricalDataForToken*(self: Controller, symbol: string, currency: string, range: int) =
|
||||||
self.tokenService.getHistoricalDataForToken(symbol, currency, range)
|
self.tokenService.getHistoricalDataForToken(symbol, currency, range)
|
||||||
|
|
||||||
|
method fetchHistoricalBalanceForTokenAsJson*(self: Controller, address: string, symbol: string, timeIntervalEnum: int) =
|
||||||
|
self.tokenService.fetchHistoricalBalanceForTokenAsJson(address, symbol, BalanceHistoryTimeInterval(timeIntervalEnum))
|
|
@ -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
|
|
|
@ -38,6 +38,12 @@ method getHistoricalDataForToken*(self: AccessInterface, symbol: string, currenc
|
||||||
method tokenHistoricalDataResolved*(self: AccessInterface, tokenDetails: string) {.base.} =
|
method tokenHistoricalDataResolved*(self: AccessInterface, tokenDetails: string) {.base.} =
|
||||||
raise newException(ValueError, "No implementation available")
|
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
|
# View Delegate Interface
|
||||||
# Delegate for the view must be declared here due to use of QtObject and multi
|
# Delegate for the view must be declared here due to use of QtObject and multi
|
||||||
# inheritance, which is not well supported in Nim.
|
# inheritance, which is not well supported in Nim.
|
||||||
|
|
|
@ -102,3 +102,10 @@ method getHistoricalDataForToken*(self: Module, symbol: string, currency: string
|
||||||
|
|
||||||
method tokenHistoricalDataResolved*(self: Module, tokenDetails: string) =
|
method tokenHistoricalDataResolved*(self: Module, tokenDetails: string) =
|
||||||
self.view.tokenHistoricalDataReady(tokenDetails)
|
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)
|
||||||
|
|
|
@ -82,3 +82,8 @@ QtObject:
|
||||||
self.delegate.getHistoricalDataForToken(symbol, currency)
|
self.delegate.getHistoricalDataForToken(symbol, currency)
|
||||||
|
|
||||||
proc tokenHistoricalDataReady*(self: View, tokenDetails: string) {.signal.}
|
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.}
|
|
@ -85,3 +85,44 @@ const getTokenHistoricalDataTask*: Task = proc(argEncoded: string) {.gcsafe, nim
|
||||||
}
|
}
|
||||||
arg.finish(output)
|
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)
|
|
@ -22,6 +22,7 @@ include async_tasks
|
||||||
const SIGNAL_TOKEN_DETAILS_LOADED* = "tokenDetailsLoaded"
|
const SIGNAL_TOKEN_DETAILS_LOADED* = "tokenDetailsLoaded"
|
||||||
const SIGNAL_TOKEN_LIST_RELOADED* = "tokenListReloaded"
|
const SIGNAL_TOKEN_LIST_RELOADED* = "tokenListReloaded"
|
||||||
const SIGNAL_TOKEN_HISTORICAL_DATA_LOADED* = "tokenHistoricalDataLoaded"
|
const SIGNAL_TOKEN_HISTORICAL_DATA_LOADED* = "tokenHistoricalDataLoaded"
|
||||||
|
const SIGNAL_BALANCE_HISTORY_DATA_READY* = "tokenBalanceHistoryDataReady"
|
||||||
|
|
||||||
type
|
type
|
||||||
TokenDetailsLoadedArgs* = ref object of Args
|
TokenDetailsLoadedArgs* = ref object of Args
|
||||||
|
@ -43,6 +44,10 @@ type
|
||||||
TokenHistoricalDataArgs* = ref object of Args
|
TokenHistoricalDataArgs* = ref object of Args
|
||||||
result*: string
|
result*: string
|
||||||
|
|
||||||
|
type
|
||||||
|
TokenBalanceHistoryDataArgs* = ref object of Args
|
||||||
|
result*: string
|
||||||
|
|
||||||
QtObject:
|
QtObject:
|
||||||
type Service* = ref object of QObject
|
type Service* = ref object of QObject
|
||||||
events: EventEmitter
|
events: EventEmitter
|
||||||
|
@ -198,7 +203,7 @@ QtObject:
|
||||||
)
|
)
|
||||||
self.threadpool.start(arg)
|
self.threadpool.start(arg)
|
||||||
|
|
||||||
proc tokenHistorticalDataResolved*(self: Service, response: string) {.slot.} =
|
proc tokenHistoricalDataResolved*(self: Service, response: string) {.slot.} =
|
||||||
let responseObj = response.parseJson
|
let responseObj = response.parseJson
|
||||||
if (responseObj.kind != JObject):
|
if (responseObj.kind != JObject):
|
||||||
info "prepared tokens are not a json object"
|
info "prepared tokens are not a json object"
|
||||||
|
@ -208,13 +213,41 @@ QtObject:
|
||||||
result: response
|
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) =
|
proc getHistoricalDataForToken*(self: Service, symbol: string, currency: string, range: int) =
|
||||||
let arg = GetTokenHistoricalDataTaskArg(
|
let arg = GetTokenHistoricalDataTaskArg(
|
||||||
tptr: cast[ByteAddress](getTokenHistoricalDataTask),
|
tptr: cast[ByteAddress](getTokenHistoricalDataTask),
|
||||||
vptr: cast[ByteAddress](self.vptr),
|
vptr: cast[ByteAddress](self.vptr),
|
||||||
slot: "tokenHistorticalDataResolved",
|
slot: "tokenHistoricalDataResolved",
|
||||||
symbol: symbol,
|
symbol: symbol,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
range: range
|
range: range
|
||||||
)
|
)
|
||||||
self.threadpool.start(arg)
|
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
|
|
@ -281,4 +281,9 @@ rpc(getDailyMarketValues, "wallet"):
|
||||||
|
|
||||||
rpc(getName, "ens"):
|
rpc(getName, "ens"):
|
||||||
chainId: int
|
chainId: int
|
||||||
address: string
|
address: string
|
||||||
|
|
||||||
|
rpc(getBalanceHistory, "wallet"):
|
||||||
|
chainId: int
|
||||||
|
address: string
|
||||||
|
timeInterval: int
|
|
@ -353,7 +353,7 @@ TEST(WalletApi, TestCheckRecentHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: this is a debugging test. Augment it with local Ganache environment to have a reliable test
|
// 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());
|
ScopedTestAccount testAccount(test_info_->name());
|
||||||
|
|
||||||
|
@ -366,6 +366,15 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain)
|
||||||
const auto updatedAccounts = Accounts::getAccounts();
|
const auto updatedAccounts = Accounts::getAccounts();
|
||||||
ASSERT_EQ(updatedAccounts.size(), 3);
|
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 =
|
const auto newAccountIt =
|
||||||
std::find_if(updatedAccounts.begin(), updatedAccounts.end(), [newTestAccountName](const auto& a) {
|
std::find_if(updatedAccounts.begin(), updatedAccounts.end(), [newTestAccountName](const auto& a) {
|
||||||
return a.name == newTestAccountName;
|
return a.name == newTestAccountName;
|
||||||
|
@ -373,18 +382,24 @@ TEST(WalletApi, TestGetBalanceHistoryOnChain)
|
||||||
ASSERT_NE(newAccountIt, updatedAccounts.end());
|
ASSERT_NE(newAccountIt, updatedAccounts.end());
|
||||||
const auto& newAccount = *newAccountIt;
|
const auto& newAccount = *newAccountIt;
|
||||||
|
|
||||||
auto testIntervals = {std::chrono::round<std::chrono::seconds>(1h),
|
auto testIntervals = {Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours,
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::days(1)),
|
Wallet::BalanceHistoryTimeInterval::BalanceHistory1Month,
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::days(7)),
|
Wallet::BalanceHistoryTimeInterval::BalanceHistory6Months,
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::months(1)),
|
Wallet::BalanceHistoryTimeInterval::BalanceHistory1Year,
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::months(6)),
|
Wallet::BalanceHistoryTimeInterval::BalanceHistoryAllTime};
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::years(1)),
|
|
||||||
std::chrono::round<std::chrono::seconds>(std::chrono::years(100))};
|
std::map<Wallet::BalanceHistoryTimeInterval, QString> testIntervalsStrs{
|
||||||
auto sampleCount = 10;
|
{Wallet::BalanceHistoryTimeInterval::BalanceHistory7Hours, "7H"},
|
||||||
for(const auto& historyDuration : testIntervals)
|
{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);
|
// TODO: next `mainNet.nativeCurrencySymbol`, later `tokens.symbol`
|
||||||
ASSERT_TRUE(balanceHistory.size() > 0); // TODO: we get one extra, match sample size
|
auto balanceHistory = Wallet::getBalanceHistory(mainNet.chainId, newAccount.address, historyInterval);
|
||||||
|
ASSERT_TRUE(balanceHistory.size() > 0);
|
||||||
|
|
||||||
auto weiToEth = [](const StatusGo::Wallet::BigInt& wei) -> double {
|
auto weiToEth = [](const StatusGo::Wallet::BigInt& wei) -> double {
|
||||||
StatusGo::Wallet::BigInt q; // wei / eth
|
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>()));
|
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))
|
if(file.open(QIODevice::WriteOnly | QIODevice::Text))
|
||||||
{
|
{
|
||||||
QTextStream out(&file);
|
QTextStream out(&file);
|
||||||
out << "Balance, Timestamp" << Qt::endl;
|
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()
|
out << weiToEth(balanceHistory[i].value) << "," << balanceHistory[i].time.toSecsSinceEpoch()
|
||||||
<< Qt::endl;
|
<< Qt::endl;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
file.close();
|
file.close();
|
||||||
sampleCount += 10;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,7 @@ Item {
|
||||||
property real minStep: 12000
|
property real minStep: 12000
|
||||||
property real maxStep: 22000
|
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},
|
property var timeRangeTabsModel: [{text: "1H", enabled: true},
|
||||||
{text: "1D", enabled: true},{text: "7D", enabled: true},
|
{text: "1D", enabled: true},{text: "7D", enabled: true},
|
||||||
{text: "1M", enabled: true}, {text: "6M", enabled: true},
|
{text: "1M", enabled: true}, {text: "6M", enabled: true},
|
||||||
|
|
|
@ -58,6 +58,11 @@ Page {
|
||||||
/*!
|
/*!
|
||||||
\qmlproperty var StatusChartPanel::graphsModel
|
\qmlproperty var StatusChartPanel::graphsModel
|
||||||
This property holds the graphs model options to be set on the left side tab bar of the header.
|
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
|
property var graphsModel
|
||||||
/*!
|
/*!
|
||||||
|
@ -81,8 +86,10 @@ Page {
|
||||||
/*!
|
/*!
|
||||||
\qmlproperty string StatusChartPanel::selectedTimeRange
|
\qmlproperty string StatusChartPanel::selectedTimeRange
|
||||||
This property holds holds the text of the current time range tab bar selected tab.
|
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
|
\qmlproperty string StatusChartPanel::defaultTimeRangeIndexShown
|
||||||
|
@ -94,15 +101,18 @@ Page {
|
||||||
\qmlsignal
|
\qmlsignal
|
||||||
This signal is emitted when a header tab bar is clicked.
|
This signal is emitted when a header tab bar is clicked.
|
||||||
*/
|
*/
|
||||||
signal headerTabClicked(string text)
|
signal headerTabClicked(var privateIdentifier, bool isTimeRange)
|
||||||
|
|
||||||
Component {
|
Component {
|
||||||
id: tabButton
|
id: tabButton
|
||||||
StatusTabButton {
|
StatusTabButton {
|
||||||
|
property var privateIdentifier: null
|
||||||
|
property bool isTimeRange: false
|
||||||
|
|
||||||
leftPadding: 0
|
leftPadding: 0
|
||||||
width: implicitWidth
|
width: implicitWidth
|
||||||
onClicked: {
|
onClicked: {
|
||||||
root.headerTabClicked(text);
|
root.headerTabClicked(privateIdentifier, isTimeRange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -111,7 +121,9 @@ Page {
|
||||||
if (!!timeRangeModel) {
|
if (!!timeRangeModel) {
|
||||||
for (var i = 0; i < timeRangeModel.length; i++) {
|
for (var i = 0; i < timeRangeModel.length; i++) {
|
||||||
var timeTab = tabButton.createObject(root, { text: timeRangeModel[i].text,
|
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.addItem(timeTab);
|
||||||
}
|
}
|
||||||
timeRangeTabBar.currentIndex = defaultTimeRangeIndexShown
|
timeRangeTabBar.currentIndex = defaultTimeRangeIndexShown
|
||||||
|
@ -119,7 +131,9 @@ Page {
|
||||||
if (!!graphsModel) {
|
if (!!graphsModel) {
|
||||||
for (var j = 0; j < graphsModel.length; j++) {
|
for (var j = 0; j < graphsModel.length; j++) {
|
||||||
var graphTab = tabButton.createObject(root, { text: graphsModel[j].text,
|
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);
|
graphsTabBar.addItem(graphTab);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -24,6 +24,13 @@ Canvas {
|
||||||
|
|
||||||
signal animationFinished()
|
signal animationFinished()
|
||||||
|
|
||||||
|
|
||||||
|
function updateToNewData()
|
||||||
|
{
|
||||||
|
jsChart.update('none');
|
||||||
|
root.requestPaint();
|
||||||
|
}
|
||||||
|
|
||||||
function animateToNewData()
|
function animateToNewData()
|
||||||
{
|
{
|
||||||
chartAnimationProgress = 0.1;
|
chartAnimationProgress = 0.1;
|
||||||
|
|
|
@ -59,7 +59,6 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
StatusSectionLayout {
|
StatusSectionLayout {
|
||||||
anchors.top: seedPhraseWarning.bottom
|
anchors.top: seedPhraseWarning.bottom
|
||||||
height: root.height - seedPhraseWarning.height
|
height: root.height - seedPhraseWarning.height
|
||||||
|
|
|
@ -117,9 +117,12 @@ Item {
|
||||||
}
|
}
|
||||||
AssetsDetailView {
|
AssetsDetailView {
|
||||||
id: assetDetailView
|
id: assetDetailView
|
||||||
|
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
Layout.fillHeight: true
|
Layout.fillHeight: true
|
||||||
visible: (stack.currentIndex === 2)
|
visible: (stack.currentIndex === 2)
|
||||||
|
|
||||||
|
address: RootStore.currentAccount.mixedcaseAddress
|
||||||
}
|
}
|
||||||
TransactionDetailView {
|
TransactionDetailView {
|
||||||
id: transactionDetailView
|
id: transactionDetailView
|
||||||
|
|
|
@ -96,12 +96,12 @@ StatusListItem {
|
||||||
tagsModel.clear()
|
tagsModel.clear()
|
||||||
if (root.keyPairAccounts === "") {
|
if (root.keyPairAccounts === "") {
|
||||||
// should never be here, as it's not possible to have keypair item without at least a single account
|
// 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
|
return
|
||||||
}
|
}
|
||||||
let obj = JSON.parse(root.keyPairAccounts)
|
let obj = JSON.parse(root.keyPairAccounts)
|
||||||
if (obj.error) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -204,4 +204,9 @@ QtObject {
|
||||||
function getHistoricalDataForToken(symbol, currency) {
|
function getHistoricalDataForToken(symbol, currency) {
|
||||||
walletSectionAllTokens.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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,97 +2,42 @@ import QtQuick 2.13
|
||||||
|
|
||||||
import utils 1.0
|
import utils 1.0
|
||||||
|
|
||||||
QtObject {
|
ChartStoreBase {
|
||||||
id: root
|
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) {
|
function setTimeAndValueData(data, range) {
|
||||||
var marketValues = []
|
var marketValues = []
|
||||||
var timeRanges = []
|
var timeRanges = []
|
||||||
for (var i = 0; i < data.length; ++i) {
|
for (var i = 0; i < data.length; ++i) {
|
||||||
marketValues[i] = data[i].close;
|
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.getDayMonth(data[i].time * 1000, RootStore.accountSensitiveSettings.is24hTimeFormat):
|
||||||
Utils.getMonthYear(data[i].time * 1000)
|
Utils.getMonthYear(data[i].time * 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch(range) {
|
switch(range) {
|
||||||
case TokenMarketValuesStore.TimeRange.Weekly: {
|
case ChartStoreBase.TimeRange.Weekly: {
|
||||||
weeklyData = marketValues
|
weeklyData = marketValues
|
||||||
weeklyTimeRange = timeRanges
|
weeklyTimeRange = timeRanges
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case TokenMarketValuesStore.TimeRange.Monthly: {
|
case ChartStoreBase.TimeRange.Monthly: {
|
||||||
monthlyData = marketValues
|
monthlyData = marketValues
|
||||||
monthlyTimeRange = timeRanges
|
monthlyTimeRange = timeRanges
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case TokenMarketValuesStore.TimeRange.HalfYearly: {
|
case ChartStoreBase.TimeRange.HalfYearly: {
|
||||||
halfYearlyData = marketValues
|
halfYearlyData = marketValues
|
||||||
halfYearlyTimeRange = timeRanges
|
halfYearlyTimeRange = timeRanges
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case TokenMarketValuesStore.TimeRange.Yearly: {
|
case ChartStoreBase.TimeRange.Yearly: {
|
||||||
yearlyData = marketValues
|
yearlyData = marketValues
|
||||||
yearlyTimeRange = timeRanges
|
yearlyTimeRange = timeRanges
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case TokenMarketValuesStore.TimeRange.All: {
|
case ChartStoreBase.TimeRange.All: {
|
||||||
allData = marketValues
|
allData = marketValues
|
||||||
allTimeRange = timeRanges
|
allTimeRange = timeRanges
|
||||||
if(data.length > 0)
|
if(data.length > 0)
|
||||||
|
|
|
@ -14,14 +14,24 @@ import shared.controls 1.0
|
||||||
|
|
||||||
import "../stores"
|
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 {
|
Item {
|
||||||
id: root
|
id: root
|
||||||
|
|
||||||
property var token
|
property var token
|
||||||
|
/*required*/ property string address: ""
|
||||||
|
|
||||||
|
function createStore(address) {
|
||||||
|
return balanceHistoryComponent.createObject(null, {address: address})
|
||||||
|
}
|
||||||
|
|
||||||
QtObject {
|
QtObject {
|
||||||
id: d
|
id: d
|
||||||
property var marketValueStore : RootStore.marketValueStore
|
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 {
|
Connections {
|
||||||
|
@ -29,7 +39,7 @@ Item {
|
||||||
onTokenHistoricalDataReady: {
|
onTokenHistoricalDataReady: {
|
||||||
let response = JSON.parse(tokenDetails)
|
let response = JSON.parse(tokenDetails)
|
||||||
if (response === null) {
|
if (response === null) {
|
||||||
console.debug("error parsing message for tokenHistoricalDataReady: error: ", response.error)
|
console.debug("error parsing json message for tokenHistoricalDataReady")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if(response.historicalData === null || response.historicalData <= 0)
|
if(response.historicalData === null || response.historicalData <= 0)
|
||||||
|
@ -59,6 +69,11 @@ Item {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum GraphType {
|
||||||
|
Price = 0,
|
||||||
|
Balance
|
||||||
|
}
|
||||||
|
|
||||||
Loader {
|
Loader {
|
||||||
id: graphDetailLoader
|
id: graphDetailLoader
|
||||||
width: parent.width
|
width: parent.width
|
||||||
|
@ -68,14 +83,66 @@ Item {
|
||||||
active: root.visible
|
active: root.visible
|
||||||
sourceComponent: StatusChartPanel {
|
sourceComponent: StatusChartPanel {
|
||||||
id: graphDetail
|
id: graphDetail
|
||||||
graphsModel: d.marketValueStore.graphTabsModel
|
|
||||||
defaultTimeRangeIndexShown: TokenMarketValuesStore.TimeRange.All
|
property int selectedGraphType: AssetsDetailView.GraphType.Price
|
||||||
timeRangeModel: d.marketValueStore.timeRangeTabsModel
|
property var selectedStore: d.marketValueStore
|
||||||
onHeaderTabClicked: chart.animateToNewData()
|
|
||||||
|
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.chartType: 'line'
|
||||||
chart.chartData: {
|
chart.chartData: {
|
||||||
return {
|
return {
|
||||||
labels: d.marketValueStore.timeRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange],
|
labels: graphDetail.labelsData,
|
||||||
datasets: [{
|
datasets: [{
|
||||||
xAxisId: 'x-axis-1',
|
xAxisId: 'x-axis-1',
|
||||||
yAxisId: 'y-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)',
|
borderColor: (Theme.palette.name === "dark") ? 'rgba(136, 176, 255, 1)' : 'rgba(67, 96, 223, 1)',
|
||||||
borderWidth: 3,
|
borderWidth: 3,
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
data: d.marketValueStore.dataRange[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange],
|
data: graphDetail.dataRange,
|
||||||
parsing: false,
|
parsing: false,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
@ -133,7 +200,7 @@ Item {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
maxRotation: 0,
|
maxRotation: 0,
|
||||||
minRotation: 0,
|
minRotation: 0,
|
||||||
maxTicksLimit: d.marketValueStore.maxTicks[graphDetail.timeRangeTabBarIndex][graphDetail.selectedTimeRange],
|
maxTicksLimit: graphDetail.maxTicksLimit,
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
yAxes: [{
|
yAxes: [{
|
||||||
|
@ -149,6 +216,10 @@ Item {
|
||||||
axis.paddingTop = 25;
|
axis.paddingTop = 25;
|
||||||
axis.paddingBottom = 0;
|
axis.paddingBottom = 0;
|
||||||
},
|
},
|
||||||
|
afterDataLimits: (axis) => {
|
||||||
|
if(axis.min < 0)
|
||||||
|
axis.min = 0;
|
||||||
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
fontColor: (Theme.palette.name === "dark") ? '#909090' : '#939BA1',
|
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")
|
tagPrimaryLabel.text: qsTr("Website")
|
||||||
controlBackground.color: Theme.palette.baseColor2
|
controlBackground.color: Theme.palette.baseColor2
|
||||||
controlBackground.border.color: "transparent"
|
controlBackground.border.color: "transparent"
|
||||||
visible: token && token.assetWebsiteUrl !== ""
|
visible: typeof token != "undefined" && token && token.assetWebsiteUrl !== ""
|
||||||
MouseArea {
|
MouseArea {
|
||||||
anchors.fill: parent
|
anchors.fill: parent
|
||||||
cursorShape: Qt.PointingHandCursor
|
cursorShape: Qt.PointingHandCursor
|
||||||
|
@ -293,7 +377,7 @@ Item {
|
||||||
tagSecondaryLabel.text: token && token.smartContractAddress !== "" ? token.smartContractAddress : "---"
|
tagSecondaryLabel.text: token && token.smartContractAddress !== "" ? token.smartContractAddress : "---"
|
||||||
controlBackground.color: Theme.palette.baseColor2
|
controlBackground.color: Theme.palette.baseColor2
|
||||||
controlBackground.border.color: "transparent"
|
controlBackground.border.color: "transparent"
|
||||||
visible: token && token.builtOn !== "" && token.smartContractAddress !== ""
|
visible: typeof token != "undefined" && token && token.builtOn !== "" && token.smartContractAddress !== ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue