diff --git a/Sources/BraveWallet/Crypto/BuySendSwap/SendTokenView.swift b/Sources/BraveWallet/Crypto/BuySendSwap/SendTokenView.swift index c06754190c6..b29ea0904dd 100644 --- a/Sources/BraveWallet/Crypto/BuySendSwap/SendTokenView.swift +++ b/Sources/BraveWallet/Crypto/BuySendSwap/SendTokenView.swift @@ -300,7 +300,7 @@ struct SendTokenView: View { didAutoShowSelectAccountToken = true } sendTokenStore.update() - await sendTokenStore.selectTokenStore.update() + sendTokenStore.selectTokenStore.setup() } .navigationViewStyle(.stack) } diff --git a/Sources/BraveWallet/Crypto/SelectAccountTokenView.swift b/Sources/BraveWallet/Crypto/SelectAccountTokenView.swift index 4030d3510a6..9331661befe 100644 --- a/Sources/BraveWallet/Crypto/SelectAccountTokenView.swift +++ b/Sources/BraveWallet/Crypto/SelectAccountTokenView.swift @@ -30,11 +30,11 @@ struct SelectAccountTokenView: View { var body: some View { List { - if store.accountSections.isEmpty { + if !store.isSetup { // Fetching accounts & assets. Typically won't see this. ProgressView() .listRowBackground(Color(.secondaryBraveGroupedBackground)) - } else if store.filteredAccountSections.flatMap(\.tokenBalances).isEmpty && !store.isLoadingBalances { + } else if store.accountSections.isEmpty && !store.isLoadingBalances { Text(Strings.Wallet.selectTokenToSendNoTokens) .font(.headline.weight(.semibold)) .foregroundColor(Color(.braveLabel)) @@ -80,7 +80,7 @@ struct SelectAccountTokenView: View { if !store.isHidingZeroBalances { return true } - return store.filteredAccountSections.flatMap(\.tokenBalances).isEmpty + return store.accountSections.isEmpty } private var networkFilterButton: some View { @@ -110,7 +110,7 @@ struct SelectAccountTokenView: View { } private var accountSections: some View { - ForEach(store.filteredAccountSections) { accountSection in + ForEach(store.accountSections) { accountSection in Section( content: { if accountSection.tokenBalances.isEmpty { diff --git a/Sources/BraveWallet/Crypto/Stores/SelectAccountTokenStore.swift b/Sources/BraveWallet/Crypto/Stores/SelectAccountTokenStore.swift index b376c31b8dd..76dd79b9271 100644 --- a/Sources/BraveWallet/Crypto/Stores/SelectAccountTokenStore.swift +++ b/Sources/BraveWallet/Crypto/Stores/SelectAccountTokenStore.swift @@ -36,69 +36,45 @@ class SelectAccountTokenStore: ObservableObject, WalletObserverStore { let tokenBalances: [TokenBalance] } - @Published var networkFilters: [Selectable] = [] - private var allNetworks: [BraveWallet.NetworkInfo] = [] - + /// The networks to filter the tokens/accounts by. + @Published var networkFilters: [Selectable] = [] { + didSet { + guard !oldValue.isEmpty else { return } // ignore initial assignment, all networks selected + updateAccountSections() + } + } + /// Indicates accounts, networks and assets are fetched + @Published var isSetup = false @Published var isLoadingBalances = false @Published var isLoadingPrices = false - @Published var isHidingZeroBalances = true - @Published var accountSections: [AccountSection] = [] - @Published var query: String - - var filteredAccountSections: [AccountSection] { - let networks = networkFilters.filter(\.isSelected).map(\.model) - var filteredAccountSections: [AccountSection] = [] - for accountSection in accountSections { - guard networks.contains(where: { $0.coin == accountSection.account.coin }) else { - // don't show account section(s) for incompatible network filter selection - continue - } - let updatedAccountSection = AccountSection( - account: accountSection.account, - tokenBalances: accountSection.tokenBalances - .filter { tokenBalance in - guard networks.contains(where: { $0.chainId == tokenBalance.network.chainId }) else { - return false - } - if !query.isEmpty, // only if we have a search query - case let normalizedQuery = query.lowercased(), - !(tokenBalance.token.symbol.lowercased().contains(normalizedQuery) || tokenBalance.token.name.lowercased().contains(normalizedQuery)) { - return false - } - if isHidingZeroBalances { - return (tokenBalance.balance ?? 0) > 0 - } - return true - } - ) - if shouldShowSection(updatedAccountSection) { - filteredAccountSections.append(updatedAccountSection) - } + /// Indicates tokens without a balance are hidden + @Published var isHidingZeroBalances = true { + didSet { + guard oldValue != isHidingZeroBalances else { return } + updateAccountSections() } - return filteredAccountSections } - - private func shouldShowSection(_ accountSection: SelectAccountTokenStore.AccountSection) -> Bool { - guard accountSection.tokenBalances.isEmpty else { - return true // non-empty + /// Each account and it's tokens + @Published var accountSections: [AccountSection] = [] + /// Filter displayed tokens by this query + @Published var query: String { + didSet { + updateAccountSections() } - // only if loading, or hiding zero balances. - return isHidingZeroBalances && isLoadingBalances } - /// The current default base currency code @Published private(set) var currencyCode: String = CurrencyCode.usd.code { didSet { currencyFormatter.currencyCode = currencyCode guard oldValue != currencyCode else { return } - Task { - await update() - } + updateAccountSections() } } let currencyFormatter: NumberFormatter = .usdCurrencyFormatter - /// Cache of balances of each asset for each account. The root key(s) are the account `address`/`id`, and the inner dictionary key(s) are the `BraveWallet.BlockchainToken.assetBalanceId`. + private var allNetworks: [BraveWallet.NetworkInfo] = [] + private var balancesFetched: Bool = false + /// Cache of balances of each asset for each account. [account.address: [token.id: balance]] private var balancesForAccountsCache: [String: [String: Double]] = [:] /// Cache of prices of assets. The key(s) are the `BraveWallet.BlockchainToken.assetRatioId` lowercased. private var pricesForTokensCache: [String: String] = [:] @@ -170,183 +146,220 @@ class SelectAccountTokenStore: ObservableObject, WalletObserverStore { .init(isSelected: true, model: $0) } query = "" + updateAccountSections() } - @MainActor func update() async { - let allAccounts = await keyringService.allAccounts() - let allNetworks = await rpcService.allNetworksForSupportedCoins() - self.allNetworks = allNetworks - // setup network filters if not currently setup - if self.networkFilters.isEmpty { + // All user accounts. + private var allAccounts: [BraveWallet.AccountInfo] = [] + // All user visible assets, key is `Identifiable.id` of `BlockchainToken`. + private var userVisibleAssets: [String: BraveWallet.BlockchainToken] = [:] + // All user accounts. + private var userVisibleNetworkAssets: [NetworkAssets] = [] + + func setup() { + Task { @MainActor in + let allAccounts = await keyringService.allAccounts().accounts + self.allAccounts = allAccounts + let allNetworks = await rpcService.allNetworksForSupportedCoins() + self.allNetworks = allNetworks self.networkFilters = allNetworks.map { .init(isSelected: true, model: $0) } - } - let allVisibleUserAssets = assetManager.getAllUserAssetsInNetworkAssetsByVisibility(networks: allNetworks, visible: true).flatMap { $0.tokens } - guard !Task.isCancelled else { return } - let tokensGroupedByCoinType = Dictionary(grouping: allVisibleUserAssets, by: \.coin) - self.accountSections = allAccounts.accounts.map { account in - let tokensForAccountCoin = tokensGroupedByCoinType[account.coin] ?? [] - let tokenBalances = tokensForAccountCoin.compactMap { token in - let tokenNetwork = allNetworks.first(where: { $0.chainId == token.chainId }) ?? .init() - if tokenNetwork.supportedKeyrings.contains(account.keyringId.rawValue as NSNumber) { - return AccountSection.TokenBalance( - token: token, - network: allNetworks.first(where: { $0.chainId == token.chainId }) ?? .init(), - balance: cachedBalance(for: token, in: account), - price: cachedPrice(for: token, in: account), - nftMetadata: cachedMetadata(for: token) - ) - } - return nil - } - return AccountSection( - account: account, - tokenBalances: tokenBalances + let allNetworkAssets = assetManager.getAllUserAssetsInNetworkAssetsByVisibility( + networks: allNetworks, + visible: true + ) + let allVisibleUserAssets = allNetworkAssets.flatMap(\.tokens) + self.userVisibleAssets = allVisibleUserAssets.reduce( + into: [String: BraveWallet.BlockchainToken](), { + $0[$1.id] = $1 + }) + // show accounts with all supported tokens until balances are fetched + self.accountSections = await buildAccountSections( + selectedNetworks: networkFilters.filter(\.isSelected).map(\.model), + allAccounts: allAccounts, + userVisibleAssets: Array(userVisibleAssets.values), + balancesCache: balancesForAccountsCache, + balancesFetched: balancesFetched, + pricesCache: pricesForTokensCache, + metadataCache: metadataCache, + hideZeroBalances: isHidingZeroBalances, + query: query, + currencyFormatter: currencyFormatter ) + self.isSetup = true + + // fetch balances for visible assets, fetch prices for tokens with balance + self.fetchAccountBalances(networkAssets: allNetworkAssets) + + // fetch metadata for visible NFT assets (user may select to show 0 balance) + let allVisibleNFTs = allVisibleUserAssets.filter { $0.isNft || $0.isErc721 } + self.fetchNFTMetadata(for: allVisibleNFTs) } - - updateAccountBalances() - updateTokenPrices() - updateNFTMetadata() } - func updateTokenPrices() { - guard !accountSections.isEmpty else { return } + func updateAccountSections() { Task { @MainActor in - self.isLoadingPrices = true - defer { self.isLoadingPrices = false } - let allAssetRatioIds = accountSections - .flatMap(\.tokenBalances) - .map(\.token) - .map(\.assetRatioId) - let prices: [String: String] = await assetRatioService.fetchPrices( - for: allAssetRatioIds, - toAssets: [currencyFormatter.currencyCode], - timeframe: .oneDay + self.accountSections = await buildAccountSections( + selectedNetworks: networkFilters.filter(\.isSelected).map(\.model), + allAccounts: allAccounts, + userVisibleAssets: Array(userVisibleAssets.values), + balancesCache: balancesForAccountsCache, + balancesFetched: balancesFetched, + pricesCache: pricesForTokensCache, + metadataCache: metadataCache, + hideZeroBalances: isHidingZeroBalances, + query: query, + currencyFormatter: currencyFormatter ) - self.pricesForTokensCache.merge(with: prices) - updateModels() } } - private var updateAccountBalancesTask: Task? - func updateAccountBalances() { - guard !accountSections.isEmpty else { return } - updateAccountBalancesTask?.cancel() - updateAccountBalancesTask = Task { @MainActor in + /// Fetch the balances for each account for the given `allNetworkAssets`, store in cache and update `accountSections`, and then fetch prices for tokens with non-zero balance. + func fetchAccountBalances(networkAssets: [NetworkAssets]) { + guard !self.allAccounts.isEmpty else { return } + Task { @MainActor in self.isLoadingBalances = true defer { isLoadingBalances = false } - for accountSection in accountSections { - let balancesForTokens: [String: Double] = await withTaskGroup( - of: [String: Double].self, - body: { @MainActor group in - for tokenBalance in accountSection.tokenBalances { - group.addTask { @MainActor in - let totalBalance = await self.rpcService.balance( - for: tokenBalance.token, - in: accountSection.account, - network: tokenBalance.network - ) - return [tokenBalance.token.assetBalanceId: totalBalance ?? 0] - } + let balancesForAccounts = await withTaskGroup( + of: [String: [String: Double]].self, + body: { group in + for account in allAccounts { + group.addTask { // get balance for all tokens this account supports + let balancesForTokens: [String: Double] = await self.rpcService.fetchBalancesForTokens( + account: account, + networkAssets: networkAssets + ) + return [account.address: balancesForTokens] } - return await group.reduce(into: [String: Double](), { partialResult, new in - for key in new.keys { - partialResult[key] = new[key] - } - }) } - ) - guard !Task.isCancelled else { return } - var updatedBalancesForTokens = (self.balancesForAccountsCache[accountSection.account.id] ?? [:]) - updatedBalancesForTokens.merge(with: balancesForTokens) - self.balancesForAccountsCache[accountSection.account.id] = updatedBalancesForTokens + return await group.reduce(into: [String: [String: Double]](), { partialResult, new in + partialResult.merge(with: new) + }) + } + ) + for account in allAccounts { + if let updatedBalancesForAccount = balancesForAccounts[account.address] { + // if balance fetch failed that we already have cached, don't overwrite existing + if var existing = self.balancesForAccountsCache[account.address] { + existing.merge(with: updatedBalancesForAccount) + self.balancesForAccountsCache[account.address] = existing + } else { + self.balancesForAccountsCache[account.address] = updatedBalancesForAccount + } + } + } + self.balancesFetched = true + self.updateAccountSections() + // fetch prices for tokens with balance + var tokensIdsWithBalance: Set = .init() + for accountBalance in balancesForAccountsCache.values { + let tokenIdsWithAccountBalance = accountBalance.filter { $1 > 0 }.map(\.key) + tokenIdsWithAccountBalance.forEach { tokensIdsWithBalance.insert($0) } } - updateModels() + let assetRatioIdsForTokensWithBalance = tokensIdsWithBalance + .compactMap { tokenId in + userVisibleAssets[tokenId]?.assetRatioId + } + self.fetchTokenPrices(for: assetRatioIdsForTokensWithBalance) } } - func updateNFTMetadata() { - guard !accountSections.isEmpty else { return } + /// Fetch the prices for the given `assetRatioIds`, store in cache and update `accountSections`. + func fetchTokenPrices(for assetRatioIds: [String]) { + guard !assetRatioIds.isEmpty else { return } Task { @MainActor in - let allNFTs = accountSections.flatMap(\.tokenBalances).map(\.token).filter { $0.isNft || $0.isErc721 } - guard !allNFTs.isEmpty else { return } - let allMetadata = await rpcService.fetchNFTMetadata(tokens: allNFTs, ipfsApi: ipfsApi) + self.isLoadingPrices = true + defer { self.isLoadingPrices = false } + let prices: [String: String] = await assetRatioService.fetchPrices( + for: assetRatioIds, + toAssets: [currencyFormatter.currencyCode], + timeframe: .oneDay + ) + self.pricesForTokensCache.merge(with: prices) + self.updateAccountSections() + } + } + + func fetchNFTMetadata(for userVisibleNFTs: [BraveWallet.BlockchainToken]) { + guard !userVisibleNFTs.isEmpty else { return } + Task { @MainActor in + let allMetadata = await rpcService.fetchNFTMetadata(tokens: userVisibleNFTs, ipfsApi: ipfsApi) self.metadataCache.merge(with: allMetadata) - self.updateModels() + self.updateAccountSections() } } - /// Updates `accountSections` with the available data from `balancesForAccountsCache` & `pricesForTokensCache`. - @MainActor private func updateModels() { - self.accountSections = accountSections.map { accountSection in - let tokenBalances: [AccountSection.TokenBalance] = accountSection.tokenBalances - .map { tokenBalance in - return AccountSection.TokenBalance( - token: tokenBalance.token, - network: tokenBalance.network, - balance: cachedBalance(for: tokenBalance.token, in: accountSection.account), - price: cachedPrice(for: tokenBalance.token, in: accountSection.account), - nftMetadata: cachedMetadata(for: tokenBalance.token) - ) - } - .sorted { lhs, rhs in - if lhs.network.isKnownTestnet && rhs.network.isKnownTestnet { - return (lhs.balance ?? 0) > (rhs.balance ?? 0) - } else if lhs.network.isKnownTestnet { - return false // sort test networks to end of list - } else if rhs.network.isKnownTestnet { - return true // sort test networks to end of list + /// Builds the array of `AccountSection`s for display, taking into account selected networks and filter query. + private func buildAccountSections( + selectedNetworks: [BraveWallet.NetworkInfo], + allAccounts: [BraveWallet.AccountInfo], + userVisibleAssets: [BraveWallet.BlockchainToken], + balancesCache: [String: [String: Double]], + balancesFetched: Bool, + pricesCache: [String: String], + metadataCache: [String: NFTMetadata], + hideZeroBalances: Bool, + query: String, + currencyFormatter: NumberFormatter + ) async -> [AccountSection] { + let accountSections: [AccountSection] = allAccounts.compactMap { account in + let tokensForAccountCoin: [BraveWallet.BlockchainToken] = userVisibleAssets + .filter({ $0.coin == account.coin }) + let accountTokenBalances: [AccountSection.TokenBalance] = tokensForAccountCoin + .compactMap { token in + if !query.isEmpty, // only if we have a filter query + !(token.symbol.localizedCaseInsensitiveContains(query) || + token.name.localizedCaseInsensitiveContains(query)) { + // token does not match query + return nil } - return (lhs.balance ?? 0) > (rhs.balance ?? 0) + // network for must be selected + if let tokenNetwork = selectedNetworks.first(where: { $0.chainId == token.chainId }), + // network must support account keyring + tokenNetwork.supportedKeyrings.contains(account.keyringId.rawValue as NSNumber) { + let balance = balancesCache[account.address]?[token.id] ?? 0 + if hideZeroBalances, balance <= 0 { + // token has no balance, user is hiding zero balance tokens. + return nil + } + var price: String? + if let tokenPrice = pricesForTokensCache[token.assetRatioId.lowercased()], + balance > 0 { + price = currencyFormatter.string(from: NSNumber(value: (Double(tokenPrice) ?? 0) * balance)) + } + return AccountSection.TokenBalance( + token: token, + network: tokenNetwork, + balance: balance, + price: price, + nftMetadata: metadataCache[token.id] + ) + } + return nil } + + if accountTokenBalances.isEmpty && balancesFetched { + // don't show this account section without token balances + return nil + } + return AccountSection( - account: accountSection.account, - tokenBalances: tokenBalances + account: account, + tokenBalances: accountTokenBalances + .sorted { lhs, rhs in + if lhs.network.isKnownTestnet && rhs.network.isKnownTestnet { + return (lhs.balance ?? 0) > (rhs.balance ?? 0) + } else if lhs.network.isKnownTestnet { + return false // sort test networks to end of list + } else if rhs.network.isKnownTestnet { + return true // sort test networks to end of list + } + return (lhs.balance ?? 0) > (rhs.balance ?? 0) + } ) } - } - - /// Helper function to get cached balance for a given account & token. - private func cachedBalance( - for token: BraveWallet.BlockchainToken, - in account: BraveWallet.AccountInfo - ) -> Double? { - balancesForAccountsCache[account.id]?[token.assetBalanceId] - } - - /// Helper function to get the formatted cached price for a given account & token. - private func cachedPrice( - for token: BraveWallet.BlockchainToken, - in account: BraveWallet.AccountInfo - ) -> String? { - if let tokenPrice = pricesForTokensCache[token.assetRatioId.lowercased()], - let tokenBalance = cachedBalance(for: token, in: account) { - return currencyFormatter.string(from: NSNumber(value: (Double(tokenPrice) ?? 0) * tokenBalance)) - } - return nil - } - - /// Helper function to get cached metadata for a given token. - private func cachedMetadata( - for token: BraveWallet.BlockchainToken - ) -> NFTMetadata? { - metadataCache[token.id] - } -} - -#if DEBUG -extension SelectAccountTokenStore { - func setupForTesting() { - allNetworks = [.mockMainnet, .mockGoerli, .mockSolana, .mockSolanaTestnet, .mockFilecoinMainnet, .mockFilecoinTestnet] - } -} -#endif - -extension Array where Element == SelectAccountTokenStore.AccountSection.TokenBalance { - func filterNonZeroBalances(shouldFilter: Bool = true) -> Self { - guard shouldFilter else { return self } - return filter { ($0.balance ?? 0) > 0 } + + return accountSections } } diff --git a/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift b/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift index 771b51d4c13..4f195c5c5a8 100644 --- a/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift +++ b/Sources/BraveWallet/Extensions/RpcServiceExtensions.swift @@ -294,6 +294,37 @@ extension BraveWalletJsonRpcService { return balancesForAsset.reduce(0, +) } + /// Returns the total balance for a given account for all of the given network assets + func fetchBalancesForTokens( + account: BraveWallet.AccountInfo, + networkAssets: [NetworkAssets] + ) async -> [String: Double] { + await withTaskGroup( + of: [String: Double].self, + body: { group in + for networkAsset in networkAssets where networkAsset.network.coin == account.coin { + for token in networkAsset.tokens { + group.addTask { + let balance = await self.balance( + for: token, + in: account, + network: networkAsset.network + ) + if let balance { + return [token.id: balance] + } else { + return [:] + } + } + } + } + return await group.reduce(into: [String: Double](), { partialResult, new in + partialResult.merge(with: new) + }) + } + ) + } + /// Returns an array of all networks for the supported coin types. Result will exclude test networks if test networks is set to /// not shown in Wallet Settings @MainActor func allNetworksForSupportedCoins(respectTestnetPreference: Bool = true) async -> [BraveWallet.NetworkInfo] { diff --git a/Tests/BraveWalletTests/SelectAccountTokenStoreTests.swift b/Tests/BraveWalletTests/SelectAccountTokenStoreTests.swift index 8d910048b10..0f6dbff0d12 100644 --- a/Tests/BraveWalletTests/SelectAccountTokenStoreTests.swift +++ b/Tests/BraveWalletTests/SelectAccountTokenStoreTests.swift @@ -239,252 +239,123 @@ import Preferences XCTFail("Unexpected test setup") return } - XCTAssertEqual(accountSections.count, 5) // 2 eth accounts, 1 sol accounts, 2 filecoin account, 1 filecoin testnet accout + XCTAssertEqual(accountSections.count, 5) // 2 eth accounts, 1 sol accounts, 2 fil accounts + + // Account 1 XCTAssertEqual(accountSections[safe: 0]?.account, .mockEthAccount) XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.MainnetChainId) + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.balance, mockETHBalance) + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.price, "$2,741.75") + XCTAssertNil(accountSections[safe: 0]?.tokenBalances[safe: 1]) // no USDC balance, token hidden + // Ethereum Account 2 XCTAssertEqual(accountSections[safe: 1]?.account, self.mockEthAccount2) XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC (Goerli) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.MainnetChainId) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.balance, mockETHBalance) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.price, "$2,741.75") + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.network.chainId, BraveWallet.GoerliChainId) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.balance, mockUSDCBalance) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.price, "$4.00") + let ethAccount2EthBalance = accountSections[safe: 1]?.tokenBalances[safe: 0]?.balance ?? 0 + let ethAccount2USDCBalance = accountSections[safe: 1]?.tokenBalances[safe: 1]?.balance ?? 0 + XCTAssertTrue(ethAccount2EthBalance < ethAccount2USDCBalance) // eth has smaller balance, but usdc on testnet + // Solana Account 1 XCTAssertEqual(accountSections[safe: 2]?.account, .mockSolAccount) XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.token, self.allUserAssets[2]) // SOL + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.SolanaMainnet) + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.balance, mockSOLBalance) + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.price, "$775.30") XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.token, self.allUserAssets[4]) // Solana NFT - XCTAssertNil(accountSections[safe: 2]?.tokenBalances[safe: 2]) // `mockSpdToken` is not visible + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.network.chainId, BraveWallet.SolanaTestnet) + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.balance, mockNFTBalance) + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.nftMetadata, mockNFTMetadata) + // Filecoin account on mainnet XCTAssertEqual(accountSections[safe: 3]?.account, .mockFilAccount) - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL on mainnet + XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL + XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.FilecoinMainnet) + XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.balance, mockFILBalance) + XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.price, "$4.06") + // Filecoin account on testnet XCTAssertEqual(accountSections[safe: 4]?.account, .mockFilTestnetAccount) - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL on testnet + XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL + XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.FilecoinTestnet) + XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.balance, mockFILBalance) + XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.price, "$4.06") }.store(in: &cancellables) - await store.update() + store.setup() await fulfillment(of: [accountSectionsExpectation], timeout: 1) + cancellables.removeAll() - // verify `filteredAccountSections` which get displayed in UI - var accountSections = store.filteredAccountSections - XCTAssertEqual(accountSections.count, 5) // 2 eth accounts, 1 sol accounts, 2 fil accounts - - // Account 1 - XCTAssertEqual(accountSections[safe: 0]?.account, .mockEthAccount) - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.MainnetChainId) - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.balance, mockETHBalance) - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.price, "$2,741.75") - XCTAssertNil(accountSections[safe: 0]?.tokenBalances[safe: 1]) // no USDC balance, token hidden - - // Ethereum Account 2 - XCTAssertEqual(accountSections[safe: 1]?.account, self.mockEthAccount2) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.MainnetChainId) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.balance, mockETHBalance) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.price, "$2,741.75") - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.network.chainId, BraveWallet.GoerliChainId) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.balance, mockUSDCBalance) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.price, "$4.00") - let ethAccount2EthBalance = accountSections[safe: 1]?.tokenBalances[safe: 0]?.balance ?? 0 - let ethAccount2USDCBalance = accountSections[safe: 1]?.tokenBalances[safe: 1]?.balance ?? 0 - XCTAssertTrue(ethAccount2EthBalance < ethAccount2USDCBalance) // eth has smaller balance, but usdc on testnet - - // Solana Account 1 - XCTAssertEqual(accountSections[safe: 2]?.account, .mockSolAccount) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.token, self.allUserAssets[2]) // SOL - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.SolanaMainnet) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.balance, mockSOLBalance) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.price, "$775.30") - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.token, self.allUserAssets[4]) // Solana NFT - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.network.chainId, BraveWallet.SolanaTestnet) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.balance, mockNFTBalance) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.nftMetadata, mockNFTMetadata) - - // Filecoin account on mainnet - XCTAssertEqual(accountSections[safe: 3]?.account, .mockFilAccount) - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.FilecoinMainnet) - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.balance, mockFILBalance) - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.price, "$4.06") - - // Filecoin account on testnet - XCTAssertEqual(accountSections[safe: 4]?.account, .mockFilTestnetAccount) - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.network.chainId, BraveWallet.FilecoinTestnet) - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.balance, mockFILBalance) - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.price, "$4.06") + let hidingZeroBalancesExpectation = expectation(description: "update-hidingZeroBalances") + store.$accountSections + .dropFirst() // initial + .sink { accountSections in + defer { hidingZeroBalancesExpectation.fulfill() } + + XCTAssertEqual(accountSections.count, 5) // 2 eth accounts, 1 sol accounts, 2 fil accounts + + // Account 1 + XCTAssertEqual(accountSections[safe: 0]?.account, .mockEthAccount) + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC + + // Ethereum Account 2 + XCTAssertEqual(accountSections[safe: 1]?.account, self.mockEthAccount2) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC (Goerli) + + // Solana Account 1 + XCTAssertEqual(accountSections[safe: 2]?.account, .mockSolAccount) + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.token, self.allUserAssets[2]) // SOL + XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.token, self.allUserAssets[4]) // Solana NFT + + // Filecoin account on mainnet + XCTAssertEqual(accountSections[safe: 3]?.account, .mockFilAccount) + XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL + + // Filecoin account on testnet + XCTAssertEqual(accountSections[safe: 4]?.account, .mockFilTestnetAccount) + XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL + }.store(in: &cancellables) // Test with zero balances shown store.isHidingZeroBalances = false - accountSections = store.filteredAccountSections - XCTAssertEqual(accountSections.count, 5) // 2 eth accounts, 1 sol accounts, 2 fil accounts - - // Account 1 - XCTAssertEqual(accountSections[safe: 0]?.account, .mockEthAccount) - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC - - // Ethereum Account 2 - XCTAssertEqual(accountSections[safe: 1]?.account, self.mockEthAccount2) - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.token, self.allUserAssets[0]) // ETH - XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 1]?.token, self.allUserAssets[1]) // USDC (Goerli) - - // Solana Account 1 - XCTAssertEqual(accountSections[safe: 2]?.account, .mockSolAccount) - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 0]?.token, self.allUserAssets[2]) // SOL - XCTAssertEqual(accountSections[safe: 2]?.tokenBalances[safe: 1]?.token, self.allUserAssets[4]) // Solana NFT + await fulfillment(of: [hidingZeroBalancesExpectation], timeout: 1) + cancellables.removeAll() - // Filecoin account on mainnet - XCTAssertEqual(accountSections[safe: 3]?.account, .mockFilAccount) - XCTAssertEqual(accountSections[safe: 3]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL + let networkFilterExpectation = expectation(description: "update-networkFilter") + store.$accountSections + .dropFirst() // initial + .sink { accountSections in + defer { networkFilterExpectation.fulfill() } + + XCTAssertEqual(accountSections.count, 2) // 2 fil accounts + // Filecoin account on mainnet + XCTAssertEqual(accountSections[safe: 0]?.account, .mockFilAccount) + XCTAssertEqual(accountSections[safe: 0]?.tokenBalances[safe: 0]?.token, self.allUserAssets[5]) // FIL + + // Filecoin account on testnet + XCTAssertEqual(accountSections[safe: 1]?.account, .mockFilTestnetAccount) + XCTAssertEqual(accountSections[safe: 1]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL + }.store(in: &cancellables) - // Filecoin account on testnet - XCTAssertEqual(accountSections[safe: 4]?.account, .mockFilTestnetAccount) - XCTAssertEqual(accountSections[safe: 4]?.tokenBalances[safe: 0]?.token, self.allUserAssets[6]) // FIL - } - - func testNetworkFilter() { - let keyringService = BraveWallet.TestKeyringService() - let rpcService = BraveWallet.TestJsonRpcService() - let walletService = BraveWallet.TestBraveWalletService() - walletService._addObserver = { _ in } - let assetRatioService = BraveWallet.TestAssetRatioService() - let mockFilecoinTestToken: BraveWallet.BlockchainToken = .mockFilToken.copy(asVisibleAsset: true).then { $0.chainId = BraveWallet.FilecoinTestnet } - - let store = SelectAccountTokenStore( - didSelect: { _, _ in }, - keyringService: keyringService, - rpcService: rpcService, - walletService: walletService, - assetRatioService: assetRatioService, - ipfsApi: TestIpfsAPI(), - userAssetManager: TestableWalletUserAssetManager() - ) - store.setupForTesting() - XCTAssertTrue(store.accountSections.isEmpty) - XCTAssertTrue(store.filteredAccountSections.isEmpty) - store.accountSections = [ - .init( - account: .mockEthAccount, - tokenBalances: [ - .init( - token: .previewToken, - network: .mockMainnet, - balance: 1 - ), - .init( - token: .previewDaiToken.copy(asVisibleAsset: true).then { $0.chainId = BraveWallet.GoerliChainId }, - network: .mockGoerli, - balance: 2 - ) - ] - ), - .init( - account: .mockSolAccount, - tokenBalances: [ - .init( - token: .mockSolToken, - network: .mockSolana, - balance: 3 - ), - .init( - token: .mockSpdToken.copy(asVisibleAsset: true).then { $0.chainId = BraveWallet.SolanaTestnet }, - network: .mockSolanaTestnet, - balance: 4 - ) - ] - ), - .init( - account: .mockFilAccount, - tokenBalances: [ - .init( - token: .mockFilToken, - network: .mockFilecoinMainnet, - balance: 1 - ) - ] - ), - .init( - account: .mockFilTestnetAccount, - tokenBalances: [ - .init( - token: mockFilecoinTestToken, - network: .mockFilecoinTestnet, - balance: 2 - ) - ] - ) - ] - // all networks + // Test with network filters applied (only Filecoin Mainnnet, Filecoin Testnet selected) store.networkFilters = [ - .init(isSelected: true, model: .mockMainnet), - .init(isSelected: true, model: .mockGoerli), - .init(isSelected: true, model: .mockSolana), - .init(isSelected: true, model: .mockSolanaTestnet), + .init(isSelected: false, model: .mockMainnet), + .init(isSelected: false, model: .mockGoerli), + .init(isSelected: false, model: .mockSolana), + .init(isSelected: false, model: .mockSolanaTestnet), .init(isSelected: true, model: .mockFilecoinMainnet), .init(isSelected: true, model: .mockFilecoinTestnet) ] - XCTAssertEqual(store.filteredAccountSections, store.accountSections) - // Ethereum mainnet - store.networkFilters = [.init(isSelected: true, model: .mockMainnet)] - XCTAssertEqual(store.filteredAccountSections.count, 1) - XCTAssertEqual(store.filteredAccountSections, [ - .init( - account: .mockEthAccount, - tokenBalances: [ - .init( - token: .previewToken, - network: .mockMainnet, - balance: 1 - ) - ] - ) - ]) - // Solana mainnet - store.networkFilters = [.init(isSelected: true, model: .mockSolana)] - XCTAssertEqual(store.filteredAccountSections.count, 1) - XCTAssertEqual(store.filteredAccountSections, [ - .init( - account: .mockSolAccount, - tokenBalances: [ - .init( - token: .mockSolToken, - network: .mockSolana, - balance: 3 - ) - ] - ) - ]) - // Filecoin mainnet - store.networkFilters = [.init(isSelected: true, model: .mockFilecoinMainnet)] - XCTAssertEqual(store.filteredAccountSections.count, 1) - XCTAssertEqual(store.filteredAccountSections, [ - .init( - account: .mockFilAccount, - tokenBalances: [ - .init( - token: .mockFilToken, - network: .mockFilecoinMainnet, - balance: 1 - ) - ] - ) - ]) - // Filecoin testnet - store.networkFilters = [.init(isSelected: true, model: .mockFilecoinTestnet)] - XCTAssertEqual(store.filteredAccountSections.count, 1) - XCTAssertEqual(store.filteredAccountSections, [ - .init( - account: .mockFilTestnetAccount, - tokenBalances: [ - .init( - token: mockFilecoinTestToken, - network: .mockFilecoinTestnet, - balance: 2 - ) - ] - ) - ]) + await fulfillment(of: [networkFilterExpectation], timeout: 1) } }