From 0b0a542828157ff572a54b848e9206a67a4daadd Mon Sep 17 00:00:00 2001 From: Eric Mastro Date: Tue, 1 Jun 2021 00:03:41 +1000 Subject: [PATCH] fix: loading of wallet history, display of tx datetime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes: #2539. Transaction history is now correctly being fetched from status-go as per mobile. Firstly, when accounts are added (ie as watch accounts), `wallet_checkRecentHistory` must be called first so that the status-go db is populated. After that, `wallet_getTransfersByAddress` can be called. On app load, when we run the `initBalance` logic, we are calling `wallet_getTransfersByAddress`, asking for the last 20 transactions with the `loadMore` parameter set to false. When the user navigates to the Wallet > History tab, they can then click “Load More” to fetch more transactions from status-go. Once the number of transactions returns false below the expected amount, the remaining transactions to fetch have been exhausted and the “Load More” button is disabled. feat: add non-archival node warning to the UI to indicate to the user that they may not have complete results feat: set active account to the added account Once an account is added to the wallet, that newly added account is selected as the active account. 1. The “load more” button is active when new transactions that aren’t already displayed are returned from `wallet_getTransfersByAddress`. This is the only way to enable or disable the “Load more” button as status-go is not able to return information regarding whether or not there are more transactions to be fetched. The downside to this is that lets say the last page of transactions is returned, but there are no more pages left. These returned txs are not currently displayed, so the “load more” button will still be enabled. However, the next click of the button will return 0 results, thus disabling it. It’s effectively an extra click to get to the disabled state. 2. For more information on how the `toBlock` parameter operates for the `wallet_getTransfersForAddress` RPC call, see https://notes.status.im/XmENTrVRRaqhwE4gK0m8Mg?view. --- devuser_guide/content/api/qml/wallet.md | 7 - docs/qml_api.md | 2 - src/app/utilsView/view.nim | 2 +- src/app/wallet/core.nim | 14 +- src/app/wallet/view.nim | 126 +++++++++---- src/app/wallet/views/account_list.nim | 4 +- src/app/wallet/views/transaction_list.nim | 18 +- src/status/libstatus/conversions.nim | 2 +- src/status/libstatus/core.nim | 5 +- src/status/libstatus/types.nim | 20 +- src/status/libstatus/utils.nim | 43 ++++- src/status/libstatus/wallet.nim | 28 +-- src/status/tasks/common.nim | 5 +- src/status/wallet.nim | 12 +- src/status/wallet/account.nim | 2 +- .../AppLayouts/Browser/BrowserWalletMenu.qml | 1 - ui/app/AppLayouts/Wallet/HistoryTab.qml | 174 +++++++++--------- ui/app/AppLayouts/Wallet/WalletLayout.qml | 2 - .../Wallet/components/AddAccount.qml | 7 + .../components/AddAccountWithPrivateKey.qml | 3 +- .../Wallet/components/AddAccountWithSeed.qml | 2 + .../Wallet/components/AddWatchOnlyAccount.qml | 3 +- .../components/GenerateAccountModal.qml | 3 +- 23 files changed, 311 insertions(+), 174 deletions(-) diff --git a/devuser_guide/content/api/qml/wallet.md b/devuser_guide/content/api/qml/wallet.md index 9822bb2518..0dba08b38a 100644 --- a/devuser_guide/content/api/qml/wallet.md +++ b/devuser_guide/content/api/qml/wallet.md @@ -288,12 +288,6 @@ returns `true` if the specified address is in the list of default or custom (use Returns stringified JSON result of the decoding. The JSON will contain only an `error` field if there was an error during decoding. Otherwise, it will contain a `symbol` (the token symbol) and an `amount` (amount approved to spend) field. -#### `isHistoryFetched(address: string)` : `bool` - -* `address` (`string`): address of the account to check - -returns `true` if `status-go` has returned transfer history for the specified account (result of `wallet_getTransfersByAddress`) - #### `loadTransactionsForAccount(address: string)` : `void` * `address` (`string`): address of the account to load transactions for @@ -343,7 +337,6 @@ The `walletModel` exposes the following signals, which can be consumed in QML us | `transactionWasSent` | `txResult` (`string`): JSON stringified result of sending a transaction | fired when accounts on the node have chagned | | `defaultCurrencyChanged` | none | fired when the user's default currency has chagned | | `gasPricePredictionsChanged` | none | fired when the gas price predictions have changed, typically after getting a gas price prediction response | -| `historyWasFetched` | none | fired when `status-go` completes fetching of transaction history | | `loadingTrxHistoryChanged` | `isLoading` (`bool`): `true` if the transaction history is loading | fired when the loading of transfer history starts and completes | | `ensWasResolved` | `resolvedAddress` (`string`): address resolved from the ENS name
`uuid` (`string`): unique identifier that was used to identify the request in QML so that only specific components can respond when needed | fired when an ENS name was resolved | | `transactionCompleted` | `success` (`bool`): `true` if the transaction was successful
`txHash` (`string`): has of the transaction
`revertReason` (`string`): reason transaction was reverted (if provided and if the transaction was reverted) | fired when a tracked transction (from the wallet or ENS) was completed | diff --git a/docs/qml_api.md b/docs/qml_api.md index 59e04afcaa..6057e8f9b9 100644 --- a/docs/qml_api.md +++ b/docs/qml_api.md @@ -19,7 +19,6 @@ The `walletModel` exposes the following signals, which can be consumed in QML us | `transactionWasSent` | `txResult` (`string`): JSON stringified result of sending a transaction | fired when accounts on the node have chagned | | `defaultCurrencyChanged` | none | fired when the user's default currency has chagned | | `gasPricePredictionsChanged` | none | fired when the gas price predictions have changed, typically after getting a gas price prediction response | -| `historyWasFetched` | none | fired when `status-go` completes fetching of transaction history | | `loadingTrxHistoryChanged` | `isLoading` (`bool`): `true` if the transaction history is loading | fired when the loading of transfer history starts and completes | | `ensWasResolved` | `resolvedAddress` (`string`): address resolved from the ENS name
`uuid` (`string`): unique identifier that was used to identify the request in QML so that only specific components can respond when needed | fired when an ENS name was resolved | | `transactionCompleted` | `success` (`bool`): `true` if the transaction was successful
`txHash` (`string`): has of the transaction
`revertReason` (`string`): reason transaction was reverted (if provided and if the transaction was reverted) | fired when a tracked transction (from the wallet or ENS) was completed | @@ -100,7 +99,6 @@ Methods can be invoked by calling them directly on the `walletModel`, ie `wallet | `isFetchingHistory` | `address` (`string`): address of the account to check | `bool` | returns `true` if `status-go` is currently fetching the transaction history for the specified account | | `isKnownTokenContract` | `address` (`string`): contract address | `bool` | returns `true` if the specified address is in the list of default or custom (user-added) contracts | | `decodeTokenApproval` | `tokenAddress` (`string`): contract address
`data` (`string`): response received from the ERC-20 token `Approve` function call | `string` | Returns stringified JSON result of the decoding. The JSON will contain only an `error` field if there was an error during decoding. Otherwise, it will contain a `symbol` (the token symbol) and an `amount` (amount approved to spend) field. | -| `isHistoryFetched` | `address` (`string`): address of the account to check | `bool` | returns `true` if `status-go` has returned transfer history for the specified account (result of `wallet_getTransfersByAddress`) | | `loadTransactionsForAccount` | `address` (`string`): address of the account to load transactions for | `void` | loads the transfer history for the specified account (result of `wallet_getTransfersByAddress`) in a separate thread | | `setTrxHistoryResult` | `historyJSON` (`string`): stringified JSON result from `status-go`'s response to `wallet_getTransfersByAddress` | `void` | sets the transaction history for the account requested. If the requested account was tracked by the `walletModel`, it will have its transactions updated (including `currentAccount`). The `loadingTrxHistoryChanged` signal is also fired with `false` as a parameter. | | `resolveENS` | `ens` (`string`): the ENS name to resolve | `void` | resolves an ENS name in a separate thread | diff --git a/src/app/utilsView/view.nim b/src/app/utilsView/view.nim index 0f0447263f..2526c2e762 100644 --- a/src/app/utilsView/view.nim +++ b/src/app/utilsView/view.nim @@ -87,7 +87,7 @@ QtObject: # somehow this value crashes the app if value == "0x0": return "0" - return stripTrailingZeroes(stint.toString(stint.fromHex(StUint[256], value))) + return $stint.fromHex(StUint[256], value) proc urlFromUserInput*(self: UtilsView, input: string): string {.slot.} = result = url_fromUserInput(input) diff --git a/src/app/wallet/core.nim b/src/app/wallet/core.nim index 8a29c26e73..7a2eb45c4f 100644 --- a/src/app/wallet/core.nim +++ b/src/app/wallet/core.nim @@ -1,4 +1,4 @@ -import NimQml, strformat, strutils, chronicles +import NimQml, strformat, strutils, chronicles, sugar, sequtils import view import views/[asset_list, account_list, account_item] @@ -34,7 +34,7 @@ proc init*(self: WalletController) = for account in accounts: self.view.addAccountToList(account) - self.view.initBalances() + self.view.checkRecentHistory() self.view.setDappBrowserAddress() self.status.events.on("accountsUpdated") do(e: Args): @@ -63,14 +63,16 @@ proc init*(self: WalletController) = # TODO: show notification of "new-transfers": - for acc in data.accounts: - self.view.loadTransactionsForAccount(acc) - self.view.initBalances(false) + self.view.initBalances(data.accounts) of "recent-history-fetching": self.view.setHistoryFetchState(data.accounts, true) of "recent-history-ready": + self.view.initBalances(data.accounts) self.view.setHistoryFetchState(data.accounts, false) - self.view.initBalances(false) + of "non-archival-node-detected": + self.view.setHistoryFetchState(self.status.wallet.accounts.map(account => account.address), false) + self.view.setNonArchivalNode() + error "Non-archival node detected, please check your Infura key or your connected node" else: error "Unhandled wallet signal", eventType=data.eventType diff --git a/src/app/wallet/view.nim b/src/app/wallet/view.nim index 2d2630659c..1dc7fa7f47 100644 --- a/src/app/wallet/view.nim +++ b/src/app/wallet/view.nim @@ -1,5 +1,6 @@ import # std libs - atomics, strformat, strutils, sequtils, json, std/wrapnils, parseUtils, tables + algorithm, atomics, sequtils, strformat, strutils, sugar, sequtils, json, + parseUtils, std/wrapnils, tables import # vendor libs NimQml, chronicles, stint @@ -36,7 +37,9 @@ type GasPredictionsTaskArg = ref object of QObjectTaskArg LoadTransactionsTaskArg = ref object of QObjectTaskArg address: string - blockNumber: string + toBlock: Uint256 + limit: int + loadMore: bool ResolveEnsTaskArg = ref object of QObjectTaskArg ens: string uuid: string @@ -144,16 +147,20 @@ const loadTransactionsTask: Task = proc(argEncoded: string) {.gcsafe, nimcall.} arg = decode[LoadTransactionsTaskArg](argEncoded) output = %*{ "address": arg.address, - "history": getTransfersByAddress(arg.address) + "history": getTransfersByAddress(arg.address, arg.toBlock, arg.limit, arg.loadMore), + "loadMore": arg.loadMore } arg.finish(output) -proc loadTransactions[T](self: T, slot: string, address: string) = +proc loadTransactions[T](self: T, slot: string, address: string, toBlock: Uint256, limit: int, loadMore: bool) = let arg = LoadTransactionsTaskArg( tptr: cast[ByteAddress](loadTransactionsTask), vptr: cast[ByteAddress](self.vptr), slot: slot, - address: address + address: address, + toBlock: toBlock, + limit: limit, + loadMore: loadMore ) self.status.tasks.threadpool.start(arg) @@ -211,6 +218,7 @@ QtObject: defaultGasLimit: string signingPhrase: string fetchingHistoryState: Table[string, bool] + isNonArchivalNode: bool proc delete(self: WalletView) = self.accounts.delete @@ -249,6 +257,7 @@ QtObject: result.defaultGasLimit = "21000" result.signingPhrase = "" result.fetchingHistoryState = initTable[string, bool]() + result.isNonArchivalNode = false result.setup proc etherscanLinkChanged*(self: WalletView) {.signal.} @@ -325,7 +334,8 @@ QtObject: self.setCurrentCollectiblesLists(selectedAccount.collectiblesLists) self.loadCollectiblesForAccount(selectedAccount.address, selectedAccount.collectiblesLists) - self.setCurrentTransactions(selectedAccount.transactions) + self.currentTransactions.setHasMore(selectedAccount.transactions.hasMore) + self.setCurrentTransactions(selectedAccount.transactions.data) proc getCurrentAccount*(self: WalletView): QVariant {.slot.} = result = newQVariant(self.currentAccount) @@ -616,6 +626,18 @@ QtObject: self.loadCollectibles("setCollectiblesResult", address, collectibleType) self.currentCollectiblesLists.setLoadingByType(collectibleType, 1) + proc isNonArchivalNodeChanged*(self: WalletView) {.signal.} + + proc setNonArchivalNode*(self: WalletView, isNonArchivalNode: bool = true) {.slot.} = + self.isNonArchivalNode = isNonArchivalNode + self.isNonArchivalNodeChanged() + + proc isNonArchivalNode*(self: WalletView): bool {.slot.} = result = ?.self.isNonArchivalNode + QtProperty[bool] isNonArchivalNode: + read = isNonArchivalNode + write = setNonArchivalNode + notify = isNonArchivalNodeChanged + proc gasPricePredictionsChanged*(self: WalletView) {.signal.} proc getGasPricePredictions*(self: WalletView) {.slot.} = @@ -685,50 +707,78 @@ QtObject: return """{"error":"Unknown token address"}"""; - proc historyWasFetched*(self: WalletView) {.signal.} + proc isFetchingHistory*(self: WalletView): bool {.slot.} = + if self.fetchingHistoryState.hasKey(self.currentAccount.address): + return self.fetchingHistoryState[self.currentAccount.address] + return false + + proc loadingTrxHistoryChanged*(self: WalletView, isLoading: bool, address: string) {.signal.} + + proc loadTransactionsForAccount*(self: WalletView, address: string, toBlock: string = "0x0", limit: int = 20, loadMore: bool = false) {.slot.} = + self.loadingTrxHistoryChanged(true, address) + let toBlockParsed = stint.fromHex(Uint256, toBlock) + self.loadTransactions("setTrxHistoryResult", address, toBlockParsed, limit, loadMore) proc setHistoryFetchState*(self: WalletView, accounts: seq[string], isFetching: bool) = for acc in accounts: self.fetchingHistoryState[acc] = isFetching - if not isFetching: self.historyWasFetched() + self.loadingTrxHistoryChanged(isFetching, acc) - proc isFetchingHistory*(self: WalletView, address: string): bool {.slot.} = - if self.fetchingHistoryState.hasKey(address): - return self.fetchingHistoryState[address] - return true + proc initBalance(self: WalletView, acc: WalletAccount, loadTransactions: bool = true) = + let + accountAddress = acc.address + tokenList = acc.assetList.filter(proc(x:Asset): bool = x.address != "").map(proc(x: Asset): string = x.address) + self.initBalances("getAccountBalanceSuccess", accountAddress, tokenList) + if loadTransactions: + self.loadTransactionsForAccount(accountAddress) - proc isHistoryFetched*(self: WalletView, address: string): bool {.slot.} = - return self.currentTransactions.rowCount() > 0 - - proc loadingTrxHistoryChanged*(self: WalletView, isLoading: bool) {.signal.} - - proc loadTransactionsForAccount*(self: WalletView, address: string) {.slot.} = - self.loadingTrxHistoryChanged(true) - self.loadTransactions("setTrxHistoryResult", address) - - proc getLatestTransactionHistory*(self: WalletView, accounts: seq[string]) = - for acc in accounts: - self.loadTransactionsForAccount(acc) + proc initBalance(self: WalletView, accountAddress: string, loadTransactions: bool = true) = + var found = false + let acc = self.status.wallet.accounts.find(acc => acc.address.toLowerAscii == accountAddress.toLowerAscii, found) + if not found: + error "Failed to init balance: could not find account", account=accountAddress + return + self.initBalance(acc, loadTransactions) proc initBalances*(self: WalletView, loadTransactions: bool = true) = for acc in self.status.wallet.accounts: - let accountAddress = acc.address - let tokenList = acc.assetList.filter(proc(x:Asset): bool = x.address != "").map(proc(x: Asset): string = x.address) - self.initBalances("getAccountBalanceSuccess", accountAddress, tokenList) - if loadTransactions: - self.loadTransactionsForAccount(accountAddress) + self.initBalance(acc, loadTransactions) + + proc initBalances*(self: WalletView, accounts: seq[string], loadTransactions: bool = true) = + for acc in accounts: + self.initBalance(acc, loadTransactions) proc setTrxHistoryResult(self: WalletView, historyJSON: string) {.slot.} = - let historyData = parseJson(historyJSON) - let transactions = historyData["history"].to(seq[Transaction]); - let address = historyData["address"].getStr - let index = self.accounts.getAccountindexByAddress(address) + let + historyData = parseJson(historyJSON) + transactions = historyData["history"].to(seq[Transaction]) + address = historyData["address"].getStr + wasFetchMore = historyData["loadMore"].getBool + isCurrentAccount = address.toLowerAscii == self.currentAccount.address.toLowerAscii + index = self.accounts.getAccountindexByAddress(address) if index == -1: return - self.accounts.getAccount(index).transactions = transactions - if address == self.currentAccount.address: - self.setCurrentTransactions( - self.accounts.getAccount(index).transactions) - self.loadingTrxHistoryChanged(false) + + let account = self.accounts.getAccount(index) + # concatenate the new page of txs to existing account transactions, + # sort them by block number and nonce, then deduplicate them based on their + # transaction id. + let existingAcctTxIds = account.transactions.data.map(tx => tx.id) + let hasNewTxs = transactions.len > 0 and transactions.any(tx => not existingAcctTxIds.contains(tx.id)) + if hasNewTxs or not wasFetchMore: + var allTxs: seq[Transaction] = account.transactions.data.concat(transactions) + allTxs.sort(cmpTransactions, SortOrder.Descending) + allTxs.deduplicate(tx => tx.id) + account.transactions.data = allTxs + account.transactions.hasMore = true + if isCurrentAccount: + self.currentTransactions.setHasMore(true) + self.setCurrentTransactions(allTxs) + else: + account.transactions.hasMore = false + if isCurrentAccount: + self.currentTransactions.setHasMore(false) + self.currentTransactionsChanged() + self.loadingTrxHistoryChanged(false, address) proc resolveENS*(self: WalletView, ens: string, uuid: string) {.slot.} = self.resolveEns("ensResolved", ens, uuid) diff --git a/src/app/wallet/views/account_list.nim b/src/app/wallet/views/account_list.nim index ec5ba6deee..ffaf0503e8 100644 --- a/src/app/wallet/views/account_list.nim +++ b/src/app/wallet/views/account_list.nim @@ -1,4 +1,4 @@ -import NimQml, Tables, random, strformat, json_serialization +import NimQml, Tables, random, strformat, strutils, json_serialization import sequtils as sequtils import account_item, asset_list from ../../../status/wallet import WalletAccount, Asset, CollectibleList @@ -54,7 +54,7 @@ QtObject: proc getAccountindexByAddress*(self: AccountList, address: string): int = var i = 0 for accountView in self.accounts: - if (accountView.account.address == address): + if (accountView.account.address.toLowerAscii == address.toLowerAscii): return i i = i + 1 return -1 diff --git a/src/app/wallet/views/transaction_list.nim b/src/app/wallet/views/transaction_list.nim index 186f3a9f94..76d580ac41 100644 --- a/src/app/wallet/views/transaction_list.nim +++ b/src/app/wallet/views/transaction_list.nim @@ -21,6 +21,7 @@ type QtObject: type TransactionList* = ref object of QAbstractListModel transactions*: seq[Transaction] + hasMore*: bool proc setup(self: TransactionList) = self.QAbstractListModel.setup @@ -31,14 +32,29 @@ QtObject: proc newTransactionList*(): TransactionList = new(result, delete) result.transactions = @[] + result.hasMore = true result.setup - proc getLastTxBlockNumber*(self: TransactionList): string = + proc getLastTxBlockNumber*(self: TransactionList): string {.slot.} = return self.transactions[^1].blockNumber method rowCount*(self: TransactionList, index: QModelIndex = nil): int = return self.transactions.len + proc hasMoreChanged*(self: TransactionList) {.signal.} + + proc getHasMore*(self: TransactionList): bool {.slot.} = + return self.hasMore + + proc setHasMore*(self: TransactionList, hasMore: bool) {.slot.} = + self.hasMore = hasMore + self.hasMoreChanged() + + QtProperty[bool] hasMore: + read = getHasMore + write = setHasMore + notify = currentTransactionsChanged + method data(self: TransactionList, index: QModelIndex, role: int): QVariant = if not index.isValid: return diff --git a/src/status/libstatus/conversions.nim b/src/status/libstatus/conversions.nim index 878235871e..5eee54698e 100644 --- a/src/status/libstatus/conversions.nim +++ b/src/status/libstatus/conversions.nim @@ -5,7 +5,7 @@ import web3/[conversions, ethtypes], stint # TODO: make this public in nim-web3 lib -template stripLeadingZeros(value: string): string = +template stripLeadingZeros*(value: string): string = var cidx = 0 # ignore the last character so we retain '0' on zero value while cidx < value.len - 1 and value[cidx] == '0': diff --git a/src/status/libstatus/core.nim b/src/status/libstatus/core.nim index 6000d9494f..19f458a9c3 100644 --- a/src/status/libstatus/core.nim +++ b/src/status/libstatus/core.nim @@ -47,8 +47,9 @@ proc getContactByID*(id: string): string = proc getBlockByNumber*(blockNumber: string): string = result = callPrivateRPC("eth_getBlockByNumber", %* [blockNumber, false]) -proc getTransfersByAddress*(address: string, limit: string, fetchMore: bool = false): string = - result = callPrivateRPC("wallet_getTransfersByAddress", %* [address, newJNull(), limit, fetchMore]) +proc getTransfersByAddress*(address: string, toBlock: string, limit: string, fetchMore: bool = false): string = + let toBlockParsed = if not fetchMore: newJNull() else: %toBlock + result = callPrivateRPC("wallet_getTransfersByAddress", %* [address, toBlockParsed, limit, fetchMore]) proc signMessage*(rpcParams: string): string = return $status_go.signMessage(rpcParams) diff --git a/src/status/libstatus/types.nim b/src/status/libstatus/types.nim index 39f5e03b46..787949864a 100644 --- a/src/status/libstatus/types.nim +++ b/src/status/libstatus/types.nim @@ -1,7 +1,11 @@ -import json, options, typetraits, tables, sequtils -import web3/ethtypes, json_serialization, stint -import accounts/constants -import ../../eventemitter +import # std libs + json, options, typetraits, tables, sequtils, strutils + +import # vendor libs + web3/ethtypes, json_serialization, stint + +import # status-desktop libs + accounts/constants, ../../eventemitter type SignalType* {.pure.} = enum Message = "messages.new" @@ -109,6 +113,7 @@ type type Transaction* = ref object + id*: string typeValue*: string address*: string blockNumber*: string @@ -123,6 +128,13 @@ type value*: string fromAddress*: string to*: string + +proc cmpTransactions*(x, y: Transaction): int = + # Sort proc to compare transactions from a single account. + # Compares first by block number, then by nonce + result = cmp(x.blockNumber.parseHexInt, y.blockNumber.parseHexInt) + if result == 0: + result = cmp(x.nonce, y.nonce) type RpcException* = object of CatchableError diff --git a/src/status/libstatus/utils.nim b/src/status/libstatus/utils.nim index 411c34c3b8..b6c5650ebf 100644 --- a/src/status/libstatus/utils.nim +++ b/src/status/libstatus/utils.nim @@ -1,9 +1,14 @@ -import json, random, strutils, strformat, tables, chronicles, unicode -import stint -from times import getTime, toUnix, nanosecond -import accounts/signing_phrases +import # std libs + json, random, strutils, strformat, tables, times, unicode +from sugar import `=>`, `->` + +import # vendor libs + stint, chronicles from web3 import Address, fromHex +import # status-desktop libs + accounts/signing_phrases + proc getTimelineChatId*(pubKey: string = ""): string = if pubKey == "": return "@timeline70bd746ddcc12beb96b2c9d572d0784ab137ffc774f5383e50585a932080b57cca0484b259e61cecbaa33a4c98a300a" @@ -116,5 +121,35 @@ proc find*[T](s: seq[T], pred: proc(x: T): bool {.closure.}): T {.inline.} = return default(type(T)) result = results[0] +proc find*[T](s: seq[T], pred: proc(x: T): bool {.closure.}, found: var bool): T {.inline.} = + let results = s.filter(pred) + if results.len == 0: + found = false + return default(type(T)) + result = results[0] + found = true + proc parseAddress*(strAddress: string): Address = fromHex(Address, strAddress) + +proc hex2Time*(hex: string): Time = + # represents the time since 1970-01-01T00:00:00Z + fromUnix(fromHex[int64](hex)) + +proc hex2LocalDateTime*(hex: string): DateTime = + # Convert hex time (since 1970-01-01T00:00:00Z) into a DateTime using the + # local timezone. + hex.hex2Time.local + +proc isUnique*[T](key: T, existingKeys: var seq[T]): bool = + # If the key doesn't exist in the existingKeys seq, add it and return true. + # Otherwise, the key already existed, so return false. + # Can be used to deduplicate sequences with `deduplicate[T]`. + if not existingKeys.contains(key): + existingKeys.add key + return true + return false + +proc deduplicate*[T](txs: var seq[T], key: (T) -> string) = + var existingKeys: seq[string] = @[] + txs.keepIf(tx => tx.key().isUnique(existingKeys)) diff --git a/src/status/libstatus/wallet.nim b/src/status/libstatus/wallet.nim index 20be200bfc..534ee70eb3 100644 --- a/src/status/libstatus/wallet.nim +++ b/src/status/libstatus/wallet.nim @@ -1,10 +1,12 @@ -import json, json, options, json_serialization, stint, chronicles -import core, types, utils, strutils, strformat -import utils -from status_go import validateMnemonic#, startWallet -import ../wallet/account -import web3/ethtypes -import ./types +import # std libs + json, times, options, strutils, strformat + +import # vendor libs + json_serialization, stint, chronicles, web3/ethtypes +from status_go import validateMnemonic + +import # status-desktop libs + ../wallet/account, ./types, ./conversions, ./core, ./types, ./utils proc getWalletAccounts*(): seq[WalletAccount] = try: @@ -33,20 +35,24 @@ proc getWalletAccounts*(): seq[WalletAccount] = proc getTransactionReceipt*(transactionHash: string): string = result = callPrivateRPC("eth_getTransactionReceipt", %* [transactionHash]) -proc getTransfersByAddress*(address: string): seq[types.Transaction] = +proc getTransfersByAddress*(address: string, toBlock: Uint256, limit: int, loadMore: bool = false): seq[types.Transaction] = try: - let transactionsResponse = getTransfersByAddress(address, "0x14") - let transactions = parseJson(transactionsResponse)["result"] + let + toBlockParsed = "0x" & stint.toHex(toBlock) + limitParsed = "0x" & limit.toHex.stripLeadingZeros + transactionsResponse = getTransfersByAddress(address, toBlockParsed, limitParsed, loadMore) + transactions = parseJson(transactionsResponse)["result"] var accountTransactions: seq[types.Transaction] = @[] for transaction in transactions: accountTransactions.add(types.Transaction( + id: transaction["id"].getStr, typeValue: transaction["type"].getStr, address: transaction["address"].getStr, contract: transaction["contract"].getStr, blockNumber: transaction["blockNumber"].getStr, blockHash: transaction["blockhash"].getStr, - timestamp: transaction["timestamp"].getStr, + timestamp: $hex2LocalDateTime(transaction["timestamp"].getStr()), gasPrice: transaction["gasPrice"].getStr, gasLimit: transaction["gasLimit"].getStr, gasUsed: transaction["gasUsed"].getStr, diff --git a/src/status/tasks/common.nim b/src/status/tasks/common.nim index ae508e5ed7..23ca501c12 100644 --- a/src/status/tasks/common.nim +++ b/src/status/tasks/common.nim @@ -1,5 +1,8 @@ import # vendor libs - json_serialization + json_serialization#, stint +from eth/common/eth_types_json_serialization import writeValue, readValue + +export writeValue, readValue export json_serialization diff --git a/src/status/wallet.nim b/src/status/wallet.nim index 713b1da5e4..094cdcf384 100644 --- a/src/status/wallet.nim +++ b/src/status/wallet.nim @@ -1,4 +1,4 @@ -import json, strformat, strutils, chronicles, sequtils, httpclient, tables, net +import json, strformat, strutils, chronicles, sequtils, sugar, httpclient, tables, net import json_serialization, stint from web3/ethtypes import Address, EthSend, Quantity from web3/conversions import `$` @@ -244,6 +244,11 @@ proc addNewGeneratedAccount(self: WalletModel, generatedAccount: GeneratedAccoun var derivedAccount: DerivedAccount = status_accounts.saveAccount(generatedAccount, password, color, accountType, isADerivedAccount, walletIndex) var account = self.newAccount(accountType, derivedAccount.derivationPath, accountName, derivedAccount.address, color, fmt"0.00 {self.defaultCurrency}", derivedAccount.publicKey) self.accounts.add(account) + # wallet_checkRecentHistory is required to be called when a new account is + # added before wallet_getTransfersByAddress can be called. This is because + # wallet_checkRecentHistory populates the status-go db that + # wallet_getTransfersByAddress reads from + discard status_wallet.checkRecentHistory(self.accounts.map(account => account.address)) self.events.emit("newAccountAdded", AccountArgs(account: account)) except Exception as e: raise newException(StatusGoException, fmt"Error adding new account: {e.msg}") @@ -314,6 +319,7 @@ proc changeAccountSettings*(self: WalletModel, address: string, accountName: str proc deleteAccount*(self: WalletModel, address: string): string = result = status_accounts.deleteAccount(address) + self.accounts = self.accounts.filter(acc => acc.address.toLowerAscii != address.toLowerAscii) proc toggleAsset*(self: WalletModel, symbol: string) = self.tokens = status_tokens.toggleAsset(symbol) @@ -333,8 +339,8 @@ proc hideAsset*(self: WalletModel, symbol: string) = proc addCustomToken*(self: WalletModel, symbol: string, enable: bool, address: string, name: string, decimals: int, color: string) = addCustomToken(address, name, symbol, decimals, color) -proc getTransfersByAddress*(self: WalletModel, address: string): seq[Transaction] = - result = status_wallet.getTransfersByAddress(address) +proc getTransfersByAddress*(self: WalletModel, address: string, toBlock: Uint256, limit: int, loadMore: bool): seq[Transaction] = + result = status_wallet.getTransfersByAddress(address, toBlock, limit, loadMore) proc validateMnemonic*(self: WalletModel, mnemonic: string): string = result = status_wallet.validateMnemonic(mnemonic).parseJSON()["error"].getStr diff --git a/src/status/wallet/account.nim b/src/status/wallet/account.nim index 4a11648ca7..c11df4a0a9 100644 --- a/src/status/wallet/account.nim +++ b/src/status/wallet/account.nim @@ -22,7 +22,7 @@ type WalletAccount* = ref object assetList*: seq[Asset] wallet*, chat*: bool collectiblesLists*: seq[CollectibleList] - transactions*: seq[Transaction] + transactions*: tuple[hasMore: bool, data: seq[Transaction]] type AccountArgs* = ref object of Args account*: WalletAccount diff --git a/ui/app/AppLayouts/Browser/BrowserWalletMenu.qml b/ui/app/AppLayouts/Browser/BrowserWalletMenu.qml index 8dcaf31e44..6ece63540f 100644 --- a/ui/app/AppLayouts/Browser/BrowserWalletMenu.qml +++ b/ui/app/AppLayouts/Browser/BrowserWalletMenu.qml @@ -189,7 +189,6 @@ Popup { anchors.leftMargin: 32 //% "History" btnText: qsTrId("history") - onClicked: historyTab.checkIfHistoryIsBeingFetched() } } diff --git a/ui/app/AppLayouts/Wallet/HistoryTab.qml b/ui/app/AppLayouts/Wallet/HistoryTab.qml index 062434b273..6b679f3b7c 100644 --- a/ui/app/AppLayouts/Wallet/HistoryTab.qml +++ b/ui/app/AppLayouts/Wallet/HistoryTab.qml @@ -9,8 +9,9 @@ import "../../../shared/status/core" import "../../../shared/status" Item { + property int pageSize: 20 // number of transactions per page property var tokens: { - const count = walletModel.defaultTokenList.rowCount() + let count = walletModel.defaultTokenList.rowCount() const toks = [] for (var i = 0; i < count; i++) { toks.push({ @@ -18,26 +19,25 @@ Item { "symbol": walletModel.defaultTokenList.rowData(i, 'symbol') }) } + count = walletModel.customTokenList.rowCount() + for (var i = 0; i < count; i++) { + toks.push({ + "address": walletModel.customTokenList.rowData(i, 'address'), + "symbol": walletModel.customTokenList.rowData(i, 'symbol') + }) + } return toks } - function checkIfHistoryIsBeingFetched() { - loadMoreButton.loadedMore = false; - - // prevent history from being fetched everytime you click on - // the history tab - if (walletModel.isHistoryFetched(walletModel.currentAccount.account)) - return; - - fetchHistory(); - } - function fetchHistory() { - if (walletModel.isFetchingHistory(walletModel.currentAccount.address)) { + if (walletModel.isFetchingHistory()) { loadingImg.active = true } else { walletModel.loadTransactionsForAccount( - walletModel.currentAccount.address) + walletModel.currentAccount.address, + walletModel.transactions.getLastTxBlockNumber(), + pageSize, + true) } } @@ -50,7 +50,6 @@ Item { anchors.right: parent.right anchors.rightMargin: Style.current.padding anchors.top: parent.top - anchors.topMargin: Style.currentPadding } Component { @@ -60,9 +59,10 @@ Item { Connections { target: walletModel - onHistoryWasFetched: checkIfHistoryIsBeingFetched() onLoadingTrxHistoryChanged: { - loadingImg.active = isLoading + if (walletModel.currentAccount.address.toLowerCase() === address.toLowerCase()) { + loadingImg.active = isLoading + } } } @@ -73,6 +73,7 @@ Item { id: transactionListItem property bool isHovered: false property string symbol: "" + property bool isIncoming: to === walletModel.currentAccount.address anchors.right: parent.right anchors.left: parent.left height: 64 @@ -107,7 +108,12 @@ Item { id: transactionModal } - Item { + Row { + anchors.left: parent.left + anchors.leftMargin: Style.current.smallPadding + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + Image { id: assetIcon width: 40 @@ -115,9 +121,7 @@ Item { source: "../../img/tokens/" + (transactionListItem.symbol != "" ? transactionListItem.symbol : "ETH") + ".png" - anchors.left: parent.left - anchors.top: parent.top - anchors.topMargin: 12 + anchors.verticalCenter: parent.verticalCenter onStatusChanged: { if (assetIcon.status == Image.Error) { assetIcon.source = "../../img/tokens/DEFAULT-TOKEN@3x.png" @@ -129,100 +133,107 @@ Item { StyledText { id: transferIcon - anchors.topMargin: 25 - anchors.top: parent.top - anchors.left: assetIcon.right - anchors.leftMargin: 22 + anchors.verticalCenter: parent.verticalCenter height: 15 width: 15 - color: to !== walletModel.currentAccount.address ? "#4360DF" : "green" - text: to !== walletModel.currentAccount.address ? "↑" : "↓" + color: isIncoming ? Style.current.success : Style.current.danger + text: isIncoming ? "↓" : "↑" } StyledText { id: transactionValue - anchors.left: transferIcon.right - anchors.leftMargin: Style.current.smallPadding - anchors.top: parent.top - anchors.topMargin: Style.current.bigPadding - font.pixelSize: 15 + anchors.verticalCenter: parent.verticalCenter + font.pixelSize: Style.current.primaryTextFontSize text: utilsModel.hex2Eth(value) + " " + transactionListItem.symbol } } - Item { + Row { anchors.right: timeInfo.left + anchors.rightMargin: Style.current.smallPadding anchors.top: parent.top anchors.topMargin: Style.current.bigPadding - width: children[0].width + children[1].width + spacing: 5 StyledText { - text: to !== walletModel.currentAccount.address ? - //% "To " - qsTrId("to-") : - //% "From " - qsTrId("from-") - anchors.right: addressValue.left + text: isIncoming ? + //% "From " + qsTrId("from-") : + //% "To " + qsTrId("to-") color: Style.current.secondaryText - anchors.top: parent.top - font.pixelSize: 15 + font.pixelSize: Style.current.primaryTextFontSize font.strikeout: false } - StyledText { + Address { id: addressValue - font.family: Style.current.fontHexRegular.name - text: to - width: 100 - elide: Text.ElideMiddle - anchors.right: parent.right - anchors.top: parent.top - font.pixelSize: 15 + text: isIncoming ? fromAddress : to + maxWidth: 120 + width: 120 + horizontalAlignment: Text.AlignRight + font.pixelSize: Style.current.primaryTextFontSize + color: Style.current.textColor } } - Item { + Row { id: timeInfo anchors.right: parent.right + anchors.rightMargin: Style.current.smallPadding anchors.top: parent.top anchors.topMargin: Style.current.bigPadding - width: children[0].width + children[1].width + children[2].width + spacing: 5 StyledText { - text: "• " + text: " • " font.weight: Font.Bold - anchors.right: timeIndicator.left color: Style.current.secondaryText - anchors.top: parent.top - font.pixelSize: 15 + font.pixelSize: Style.current.primaryTextFontSize } StyledText { id: timeIndicator - text: "At " - anchors.right: timeValue.left + text: qsTr("At ") color: Style.current.secondaryText - anchors.top: parent.top - font.pixelSize: 15 + font.pixelSize: Style.current.primaryTextFontSize font.strikeout: false } - StyledText { id: timeValue - text: timestamp - anchors.right: parent.right - anchors.top: parent.top - font.pixelSize: 15 + text: new Date(timestamp).toLocaleString(globalSettings.locale) + font.pixelSize: Style.current.primaryTextFontSize anchors.rightMargin: Style.current.smallPadding } } } } + StyledText { + id: nonArchivalNodeError + visible: walletModel.isNonArchivalNode + height: visible ? implicitHeight : 0 + anchors.top: parent.top + text: qsTr("Status Desktop is connected to a non-archival node. Transaction history may be incomplete.") + font.pixelSize: Style.current.primaryTextFontSize + color: Style.current.danger + } + + StyledText { + id: noTxs + anchors.top: nonArchivalNodeError.bottom + visible: transactionListRoot.count === 0 + height: visible ? implicitHeight : 0 + text: qsTr("No transactions found") + font.pixelSize: Style.current.primaryTextFontSize + } + ListView { id: transactionListRoot - anchors.topMargin: 20 - height: parent.height - extraButtons.height + anchors.top: noTxs.bottom + anchors.topMargin: Style.current.padding + anchors.bottom: loadMoreButton.top + anchors.bottomMargin: Style.current.padding width: parent.width clip: true boundsBehavior: Flickable.StopAtBounds @@ -238,26 +249,21 @@ Item { } } - RowLayout { - id: extraButtons - anchors.left: parent.left + StatusButton { + id: loadMoreButton + //% "Load More" + text: qsTrId("load-more") + // TODO: handle case when requested limit === transaction count -- there + // is currently no way to know that there are no more results + enabled: !loadingImg.active && walletModel.transactions.hasMore anchors.right: parent.right anchors.bottom: parent.bottom - height: loadMoreButton.height + anchors.bottomMargin: Style.current.padding + property bool loadedMore: false - StatusButton { - id: loadMoreButton - //% "Load More" - text: qsTrId("load-more") - Layout.alignment: Qt.AlignRight | Qt.AlignVCenter - property bool loadedMore: false - - Connections { - onClicked: { - fetchHistory() - loadMoreButton.loadedMore = true - } - } + onClicked: { + fetchHistory() + loadMoreButton.loadedMore = true } } } diff --git a/ui/app/AppLayouts/Wallet/WalletLayout.qml b/ui/app/AppLayouts/Wallet/WalletLayout.qml index 222243ce4a..36a237876e 100644 --- a/ui/app/AppLayouts/Wallet/WalletLayout.qml +++ b/ui/app/AppLayouts/Wallet/WalletLayout.qml @@ -23,7 +23,6 @@ ColumnLayout { onboardingModel.firstTimeLogin = false walletModel.setInitialRange() } - walletModel.checkRecentHistory() } Timer { @@ -120,7 +119,6 @@ ColumnLayout { anchors.leftMargin: 32 //% "History" btnText: qsTrId("history") - onClicked: historyTab.checkIfHistoryIsBeingFetched() } } diff --git a/ui/app/AppLayouts/Wallet/components/AddAccount.qml b/ui/app/AppLayouts/Wallet/components/AddAccount.qml index e929b2f284..e6573e1649 100644 --- a/ui/app/AppLayouts/Wallet/components/AddAccount.qml +++ b/ui/app/AppLayouts/Wallet/components/AddAccount.qml @@ -12,6 +12,9 @@ StatusRoundButton { type: "secondary" width: 36 height: 36 + readonly property var onAfterAddAccount: function() { + walletInfoContainer.changeSelectedAccount(walletModel.accounts.rowCount() - 1) + } onClicked: { if (newAccountMenu.opened) { @@ -24,15 +27,19 @@ StatusRoundButton { GenerateAccountModal { id: generateAccountModal + onAfterAddAccount: function() { btnAdd.onAfterAddAccount() } } AddAccountWithSeed { id: addAccountWithSeedModal + onAfterAddAccount: function() { btnAdd.onAfterAddAccount() } } AddAccountWithPrivateKey { id: addAccountWithPrivateKeydModal + onAfterAddAccount: function() { btnAdd.onAfterAddAccount() } } AddWatchOnlyAccount { id: addWatchOnlyAccountModal + onAfterAddAccount: function() { btnAdd.onAfterAddAccount() } } PopupMenu { diff --git a/ui/app/AppLayouts/Wallet/components/AddAccountWithPrivateKey.qml b/ui/app/AppLayouts/Wallet/components/AddAccountWithPrivateKey.qml index 46dedbf48a..5dd9c6b0ff 100644 --- a/ui/app/AppLayouts/Wallet/components/AddAccountWithPrivateKey.qml +++ b/ui/app/AppLayouts/Wallet/components/AddAccountWithPrivateKey.qml @@ -16,6 +16,7 @@ ModalPopup { property string privateKeyValidationError: "" property string accountNameValidationError: "" property bool loading: false + property var onAfterAddAccount: function() {} function validate() { if (passwordInput.text === "") { @@ -141,7 +142,7 @@ ModalPopup { } return } - + popup.onAfterAddAccount() popup.close(); } } diff --git a/ui/app/AppLayouts/Wallet/components/AddAccountWithSeed.qml b/ui/app/AppLayouts/Wallet/components/AddAccountWithSeed.qml index 9fb2c52e79..0fbc724000 100644 --- a/ui/app/AppLayouts/Wallet/components/AddAccountWithSeed.qml +++ b/ui/app/AppLayouts/Wallet/components/AddAccountWithSeed.qml @@ -14,6 +14,7 @@ ModalPopup { property string seedValidationError: "" property string accountNameValidationError: "" property bool loading: false + property var onAfterAddAccount: function() {} function reset() { passwordInput.text = "" @@ -142,6 +143,7 @@ ModalPopup { } return } + popup.onAfterAddAccount() popup.reset() popup.close(); } diff --git a/ui/app/AppLayouts/Wallet/components/AddWatchOnlyAccount.qml b/ui/app/AppLayouts/Wallet/components/AddWatchOnlyAccount.qml index d477466aca..192bc101ec 100644 --- a/ui/app/AppLayouts/Wallet/components/AddWatchOnlyAccount.qml +++ b/ui/app/AppLayouts/Wallet/components/AddWatchOnlyAccount.qml @@ -14,6 +14,7 @@ ModalPopup { property string addressError: "" property string accountNameValidationError: "" property bool loading: false + property var onAfterAddAccount: function() {} function validate() { if (addressInput.text === "") { @@ -107,7 +108,7 @@ ModalPopup { accountError.text = error return accountError.open() } - + popup.onAfterAddAccount() popup.close(); } } diff --git a/ui/app/AppLayouts/Wallet/components/GenerateAccountModal.qml b/ui/app/AppLayouts/Wallet/components/GenerateAccountModal.qml index a51939f620..12141cf68f 100644 --- a/ui/app/AppLayouts/Wallet/components/GenerateAccountModal.qml +++ b/ui/app/AppLayouts/Wallet/components/GenerateAccountModal.qml @@ -14,6 +14,7 @@ ModalPopup { property string passwordValidationError: "" property string accountNameValidationError: "" property bool loading: false + property var onAfterAddAccount: function() {} function validate() { if (passwordInput.text === "") { @@ -114,7 +115,7 @@ ModalPopup { } return } - + popup.onAfterAddAccount(); popup.close(); } }