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