From 9af000976e17fcbbc280cb2dfbb40a6a9ff83eb0 Mon Sep 17 00:00:00 2001 From: Vladyslav shepitko Date: Tue, 19 Jan 2021 16:13:14 +0200 Subject: [PATCH] Speed up app launch #2374 --- AlphaWallet.xcodeproj/project.pbxproj | 4 + .../xcschemes/AlphaWalletTests.xcscheme | 22 +- .../Coordinators/ActivitiesCoordinator.swift | 95 +++++-- .../ActivitiesViewController.swift | 11 +- .../ViewModels/ActivitiesViewModel.swift | 12 +- ...ionInitializerForOneChainPerDatabase.swift | 8 +- .../Core/Types/AlphaWalletAddress.swift | 32 ++- .../Types/AlphaWalletAddressExtension.swift | 9 +- .../TrustClient/Models/RawTransaction.swift | 24 +- AlphaWallet/InCoordinator.swift | 14 +- AlphaWallet/Settings/Types/RPCServers.swift | 2 +- .../Coordinators/EventSourceCoordinator.swift | 67 +++-- .../EventSourceCoordinatorForActivities.swift | 129 ++++++--- .../TokenScriptClient/Models/Activity.swift | 23 +- .../Models/ActivityOrTransaction.swift | 71 +++++ .../Models/AssetAttributeValues.swift | 6 +- .../Models/EventsActivityDataStore.swift | 45 +++- .../Models/EventsDataStore.swift | 52 ++-- .../TokenScriptClient/Models/XMLHandler.swift | 51 +++- .../GetBlockTimestampCoordinator.swift | 25 +- .../GetContractInteractions.swift | 112 ++++---- .../GetIsERC721ContractCoordinator.swift | 126 +++++---- .../SingleChainTokenCoordinator.swift | 6 +- .../Coordinators/TokensCoordinator.swift | 4 +- .../Helpers/CallSmartContractFunction.swift | 102 +++++-- AlphaWallet/Tokens/Types/EventActivity.swift | 85 ++++++ AlphaWallet/Tokens/Types/EventInstance.swift | 24 +- .../Tokens/Types/EventInstanceValue.swift | 62 +++++ .../Tokens/Types/TokenCollection.swift | 14 +- AlphaWallet/Tokens/Types/TokenObject.swift | 32 ++- .../Tokens/Types/TokensDataStore.swift | 23 +- .../Tokens/Types/TransactionCollection.swift | 13 +- .../ViewControllers/TokenViewController.swift | 2 +- ...ewControllerTransactionCellViewModel.swift | 4 +- .../TokenViewControllerViewModel.swift | 27 +- .../OpenSeaNonFungibleTokenViewCell.swift | 5 +- ...ingleChainTransactionDataCoordinator.swift | 1 + ...nTransactionEtherscanDataCoordinator.swift | 251 +++++++++++------- .../Coordinators/TransactionCoordinator.swift | 2 +- .../TransactionDataCoordinator.swift | 22 +- .../Transactions/Storage/Transaction.swift | 122 ++++++++- .../Storage/TransactionsStorage.swift | 130 +++++++-- .../Types/LocalizedOperationObject.swift | 84 +++++- .../TransactionViewController.swift | 8 +- .../TransactionsViewController.swift | 42 +-- .../ViewModels/TransactionRow.swift | 12 +- .../TransactionRowCellViewModel.swift | 2 +- .../ViewModels/TransactionsViewModel.swift | 19 +- AlphaWallet/UI/TokenObject+UI.swift | 28 +- AlphaWalletTests/Factories/Transaction.swift | 36 +++ .../FakeEventsDataStore.swift | 13 +- 51 files changed, 1552 insertions(+), 563 deletions(-) create mode 100644 AlphaWallet/TokenScriptClient/Models/ActivityOrTransaction.swift create mode 100644 AlphaWallet/Tokens/Types/EventInstanceValue.swift diff --git a/AlphaWallet.xcodeproj/project.pbxproj b/AlphaWallet.xcodeproj/project.pbxproj index 5ef43979e..b85e4f3b2 100644 --- a/AlphaWallet.xcodeproj/project.pbxproj +++ b/AlphaWallet.xcodeproj/project.pbxproj @@ -722,6 +722,7 @@ 878EE952255BEC20000210DE /* ItemType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878EE950255BEC20000210DE /* ItemType.swift */; }; 878EE954255BFFB9000210DE /* FeedbackGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 878EE953255BFFB9000210DE /* FeedbackGenerator.swift */; }; 8797362524E6C20C0042BBCC /* TransactionConfirmationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8797362424E6C20C0042BBCC /* TransactionConfirmationCoordinator.swift */; }; + 87A0C93225AEF1E400E73F60 /* EventInstanceValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A0C93125AEF1E400E73F60 /* EventInstanceValue.swift */; }; 87A3020924BEE243000DF32E /* TransactionInProgressViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A3020824BEE243000DF32E /* TransactionInProgressViewController.swift */; }; 87A3020B24BF04B6000DF32E /* TransactionInProgressViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A3020A24BF04B6000DF32E /* TransactionInProgressViewModel.swift */; }; 87A3022724C02212000DF32E /* TransactionConfirmationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87A3022624C02212000DF32E /* TransactionConfirmationViewController.swift */; }; @@ -1575,6 +1576,7 @@ 878EE950255BEC20000210DE /* ItemType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemType.swift; sourceTree = ""; }; 878EE953255BFFB9000210DE /* FeedbackGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackGenerator.swift; sourceTree = ""; }; 8797362424E6C20C0042BBCC /* TransactionConfirmationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionConfirmationCoordinator.swift; sourceTree = ""; }; + 87A0C93125AEF1E400E73F60 /* EventInstanceValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventInstanceValue.swift; sourceTree = ""; }; 87A3020824BEE243000DF32E /* TransactionInProgressViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionInProgressViewController.swift; sourceTree = ""; }; 87A3020A24BF04B6000DF32E /* TransactionInProgressViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionInProgressViewModel.swift; sourceTree = ""; }; 87A3022624C02212000DF32E /* TransactionConfirmationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionConfirmationViewController.swift; sourceTree = ""; }; @@ -2351,6 +2353,7 @@ 5E7C7B43816C35C3FE2EFFBE /* TokenInstanceAction.swift */, 5E7C74822F5F71748184F6C1 /* EventInstance.swift */, 5E7C737CC822F8143DE1FDC0 /* EventActivity.swift */, + 87A0C93125AEF1E400E73F60 /* EventInstanceValue.swift */, ); path = Types; sourceTree = ""; @@ -4559,6 +4562,7 @@ 29F114F21FA7966300114A29 /* PrivateKeyRule.swift in Sources */, 29F1C853200363B2003780D8 /* PassphraseViewController.swift in Sources */, 29E2E33A1F7A008C000CF94A /* UIView.swift in Sources */, + 87A0C93225AEF1E400E73F60 /* EventInstanceValue.swift in Sources */, 299B5E491FD2C8900051361C /* ConfigureTransaction.swift in Sources */, 77872D25202505B70032D687 /* EnterPasswordViewController.swift in Sources */, 87E2554C24F52E0600F025F7 /* TextFieldTableViewCell.swift in Sources */, diff --git a/AlphaWallet.xcworkspace/xcshareddata/xcschemes/AlphaWalletTests.xcscheme b/AlphaWallet.xcworkspace/xcshareddata/xcschemes/AlphaWalletTests.xcscheme index 3dde9ce5e..7ebe7ea94 100644 --- a/AlphaWallet.xcworkspace/xcshareddata/xcschemes/AlphaWalletTests.xcscheme +++ b/AlphaWallet.xcworkspace/xcshareddata/xcschemes/AlphaWalletTests.xcscheme @@ -27,6 +27,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> + + + + @@ -39,17 +48,6 @@ - - - - - - - - diff --git a/AlphaWallet/Activities/Coordinators/ActivitiesCoordinator.swift b/AlphaWallet/Activities/Coordinators/ActivitiesCoordinator.swift index 4d05ed0dc..275e3768a 100644 --- a/AlphaWallet/Activities/Coordinators/ActivitiesCoordinator.swift +++ b/AlphaWallet/Activities/Coordinators/ActivitiesCoordinator.swift @@ -3,7 +3,7 @@ import UIKit protocol ActivitiesCoordinatorDelegate: class { - func didPressTransaction(transaction: Transaction, in viewController: ActivitiesViewController) + func didPressTransaction(transaction: TransactionInstance, in viewController: ActivitiesViewController) func show(tokenObject: TokenObject, fromCoordinator coordinator: ActivitiesCoordinator) func show(transactionWithId transactionId: String, server: RPCServer, inViewController viewController: UIViewController, fromCoordinator coordinator: ActivitiesCoordinator) func didPressViewContractWebPage(forContract contract: AlphaWallet.Address, server: RPCServer, fromCoordinator coordinator: ActivitiesCoordinator, inViewController viewController: UIViewController) @@ -20,8 +20,9 @@ class ActivitiesCoordinator: Coordinator { //Dictionary for lookup. Using `.firstIndex` too many times is too slow (60s for 10k events) private var activitiesIndexLookup: [Int: (index: Int, activity: Activity)] = .init() private var activities: [Activity] = .init() - private var transactions: [Transaction] = .init() - private var tokensAndTokenHolders: [AlphaWallet.Address: (tokenObject: TokenObject, tokenHolders: [TokenHolder])] = .init() + private var transactions: [TransactionInstance] = .init() + + private var tokensAndTokenHolders: [AlphaWallet.Address: (tokenObject: Activity.AssignedToken, tokenHolders: [TokenHolder])] = .init() weak private var activityViewController: ActivityViewController? private var rateLimitedUpdater: RateLimiter? private var rateLimitedViewControllerReloader: RateLimiter? @@ -45,6 +46,7 @@ class ActivitiesCoordinator: Coordinator { let navigationController: UINavigationController var coordinators: [Coordinator] = [] + private let queue = DispatchQueue(label: "com.activities.updateQueue") init( config: Config, @@ -91,7 +93,7 @@ class ActivitiesCoordinator: Coordinator { } @objc func dismiss() { - navigationController.dismiss(animated: true, completion: nil) + navigationController.dismiss(animated: true) } func stop() { @@ -104,7 +106,11 @@ class ActivitiesCoordinator: Coordinator { func reload() { if rateLimitedUpdater == nil { rateLimitedUpdater = RateLimiter(name: "Fetch activity from events in database", limit: 5, autoRun: true) { [weak self] in - self?.reloadImpl() + guard let strongSelf = self else { return } + + strongSelf.queue.async { + strongSelf.reloadImpl() + } } } else { rateLimitedUpdater?.run() @@ -153,13 +159,14 @@ class ActivitiesCoordinator: Coordinator { } return contractAndCard } + let contractsAndCards = contractsAndCardsOptional.flatMap { $0 } fetchAndRefreshActivities(contractsAndCards: contractsAndCards) } private func fetchAndRefreshActivities(contractsAndCards: [(tokenContract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String)]) { let recentEvents = eventsActivityDataStore.getRecentEvents() - var activitiesAndTokens: [(Activity, TokenObject, TokenHolder)] = .init() + var activitiesAndTokens: [(Activity, Activity.AssignedToken, TokenHolder)] = .init() for (eachContract, eachServer, card, interpolatedFilter) in contractsAndCards { let activities = getActivities(recentEvents, forTokenContract: eachContract, server: eachServer, card: card, interpolatedFilter: interpolatedFilter) activitiesAndTokens.append(contentsOf: activities) @@ -175,37 +182,41 @@ class ActivitiesCoordinator: Coordinator { } } - private func getActivities(_ allActivities: [EventActivity], forTokenContract contract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String) -> [(Activity, TokenObject, TokenHolder)] { - let events = allActivities.filter { $0.contract == card.eventOrigin.contract.eip55String + private func getActivities(_ allActivities: [EventActivity], forTokenContract contract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String) -> [(Activity, Activity.AssignedToken, TokenHolder)] { + let events = allActivities.filter { + $0.contract == card.eventOrigin.contract.eip55String && $0.server == server && $0.eventName == card.eventOrigin.eventName && $0.filter == interpolatedFilter } //Cache tokens lookup for performance - var tokensCache: [AlphaWallet.Address: TokenObject] = .init() - let activitiesForThisCard: [(activity: Activity, tokenObject: TokenObject, tokenHolder: TokenHolder)] = events.compactMap { eachEvent in - let token: TokenObject + var tokensCache: [AlphaWallet.Address: Activity.AssignedToken] = .init() + let activitiesForThisCard: [(activity: Activity, tokenObject: Activity.AssignedToken, tokenHolders: TokenHolder)] = events.compactMap { eachEvent in + let token: Activity.AssignedToken if let t = tokensCache[contract] { token = t } else { let tokensDatastore = tokensStorages[server] - guard let t = tokensDatastore.token(forContract: contract) else { return nil } - tokensCache[contract] = t - token = t + guard let t = tokensDatastore.tokenThreadSafe(forContract: contract) else { return nil } + let tt = Activity.AssignedToken(tokenObject: t) + tokensCache[contract] = tt + token = tt } let implicitAttributes = generateImplicitAttributesForToken(forContract: contract, server: server, symbol: token.symbol) let tokenAttributes = implicitAttributes var cardAttributes = generateImplicitAttributesForCard(forContract: contract, server: server, event: eachEvent) cardAttributes.merge(eachEvent.data) { _, new in new } + for parameter in card.eventOrigin.parameters { guard let originalValue = cardAttributes[parameter.name] else { continue } guard let type = SolidityType(rawValue: parameter.type) else { continue } let translatedValue = type.coerce(value: originalValue) cardAttributes[parameter.name] = translatedValue } - let tokenObject: TokenObject + + let tokenObject: Activity.AssignedToken let tokenHolders: [TokenHolder] if let (o, h) = tokensAndTokenHolders[contract] { tokenObject = o @@ -213,13 +224,22 @@ class ActivitiesCoordinator: Coordinator { } else { tokenObject = token if tokenObject.contractAddress.sameContract(as: Constants.nativeCryptoAddressInDatabase) { - tokenHolders = [TokenHolder(tokens: [Token(tokenIdOrEvent: .tokenId(tokenId: .init(1)), tokenType: .nativeCryptocurrency, index: 0, name: "", symbol: "", status: .available, values: .init())], contractAddress: tokenObject.contractAddress, hasAssetDefinition: true)] + let token = Token(tokenIdOrEvent: .tokenId(tokenId: .init(1)), tokenType: .nativeCryptocurrency, index: 0, name: "", symbol: "", status: .available, values: .init()) + + tokenHolders = [TokenHolder(tokens: [token], contractAddress: tokenObject.contractAddress, hasAssetDefinition: true)] } else { - tokenHolders = TokenAdaptor(token: tokenObject, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: sessions.anyValue.account) + //NOTE: because this can be called from different threads we can use cache here, but we can cache Activity.AssignedToken + let tokensDatastore = tokensStorages[server] + guard let t = tokensDatastore.tokenThreadSafe(forContract: tokenObject.contractAddress) else { return nil } + + tokenHolders = TokenAdaptor(token: t, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: sessions.anyValue.account) } tokensAndTokenHolders[contract] = (tokenObject: tokenObject, tokenHolders: tokenHolders) } - return (activity: .init(id: Int.random(in: 0..= blockNumberOfOldestActivity } } else { @@ -268,13 +293,18 @@ class ActivitiesCoordinator: Coordinator { } let items = combine(activities: activities, withTransactions: transactions) + if let items = items { - rootViewController.configure(viewModel: .init(tokensStorages: tokensStorages, activities: items)) + let activities = ActivitiesViewModel.sorted(activities: items) + + DispatchQueue.main.async { + self.rootViewController.configure(viewModel: .init(tokensStorages: self.tokensStorages, activities: activities)) + } } } //Combining includes filtering around activities (from events) for ERC20 send/receive transctions which are already covered by transactions - private func combine(activities: [Activity], withTransactions: [Transaction]) -> [ActivityOrTransactionRow]? { + private func combine(activities: [Activity], withTransactions: [TransactionInstance]) -> [ActivityOrTransactionRow]? { var transactionRows: [TransactionRow] = .init() for each in transactions { if each.localizedOperations.isEmpty { @@ -337,17 +367,20 @@ class ActivitiesCoordinator: Coordinator { } //Important to pass in the `TokenHolder` instance and not re-create so that we don't override the subscribable values for the token with ones that are not resolved yet - private func refreshActivity(tokenObject: TokenObject, tokenHolder: TokenHolder, activity: Activity, isFirstUpdate: Bool = true) { + private func refreshActivity(tokenObject: Activity.AssignedToken, tokenHolder: TokenHolder, activity: Activity, isFirstUpdate: Bool = true) { let attributeValues = AssetAttributeValues(attributeValues: tokenHolder.values) let resolvedAttributeNameValues = attributeValues.resolve { [weak self, weak tokenHolder] _ in guard let strongSelf = self, let tokenHolder = tokenHolder, isFirstUpdate else { return } strongSelf.refreshActivity(tokenObject: tokenObject, tokenHolder: tokenHolder, activity: activity, isFirstUpdate: false) } + if let (index, oldActivity) = activitiesIndexLookup[activity.id] { let updatedValues = (token: oldActivity.values.token.merging(resolvedAttributeNameValues) { _, new in new }, card: oldActivity.values.card) let updatedActivity: Activity = .init(id: oldActivity.id, rowType: oldActivity.rowType, tokenObject: tokenObject, server: oldActivity.server, name: oldActivity.name, eventName: oldActivity.eventName, blockNumber: oldActivity.blockNumber, transactionId: oldActivity.transactionId, transactionIndex: oldActivity.transactionIndex, logIndex: oldActivity.logIndex, date: oldActivity.date, values: updatedValues, view: oldActivity.view, itemView: oldActivity.itemView, isBaseCard: oldActivity.isBaseCard, state: oldActivity.state) + activities[index] = updatedActivity reloadViewController(reloadImmediately: false) + if let activityViewController = activityViewController, activityViewController.isForActivity(updatedActivity) { activityViewController.configure(viewModel: .init(activity: updatedActivity)) } @@ -401,20 +434,24 @@ extension ActivitiesCoordinator: ActivitiesViewControllerDelegate { showActivity(activity) } - func didPressTransaction(transaction: Transaction, in viewController: ActivitiesViewController) { + func didPressTransaction(transaction: TransactionInstance, in viewController: ActivitiesViewController) { delegate?.didPressTransaction(transaction: transaction, in: viewController) } } extension ActivitiesCoordinator: ActivityViewControllerDelegate { func reinject(viewController: ActivityViewController) { - guard let (tokenObject, tokenHolder) = tokensAndTokenHolders[viewController.viewModel.activity.tokenObject.contractAddress] else { return } + guard let (token, tokenHolder) = tokensAndTokenHolders[viewController.viewModel.activity.tokenObject.contractAddress] else { return } let activity = viewController.viewModel.activity - refreshActivity(tokenObject: tokenObject, tokenHolder: tokenHolder[0], activity: activity) + + refreshActivity(tokenObject: token, tokenHolder: tokenHolder[0], activity: activity) } func goToToken(viewController: ActivityViewController) { - delegate?.show(tokenObject: viewController.viewModel.activity.tokenObject, fromCoordinator: self) + let token = viewController.viewModel.activity.tokenObject + guard let tokenObject = tokensStorages[token.server].token(forContract: token.contractAddress) else { return } + + delegate?.show(tokenObject: tokenObject, fromCoordinator: self) } func goToTransaction(viewController: ActivityViewController) { @@ -427,7 +464,7 @@ extension ActivitiesCoordinator: ActivityViewControllerDelegate { } extension ActivitiesCoordinator: TransactionDataCoordinatorDelegate { - func didUpdate(result: ResultResult<[Transaction], TransactionError>.t, reloadImmediately: Bool) { + func didUpdate(result: ResultResult<[TransactionInstance], TransactionError>.t, reloadImmediately: Bool) { switch result { case .success(let items): transactions = items diff --git a/AlphaWallet/Activities/ViewControllers/ActivitiesViewController.swift b/AlphaWallet/Activities/ViewControllers/ActivitiesViewController.swift index fc5376a63..ce9e54b64 100644 --- a/AlphaWallet/Activities/ViewControllers/ActivitiesViewController.swift +++ b/AlphaWallet/Activities/ViewControllers/ActivitiesViewController.swift @@ -6,7 +6,7 @@ import StatefulViewController protocol ActivitiesViewControllerDelegate: class { func didPressActivity(activity: Activity, in viewController: ActivitiesViewController) - func didPressTransaction(transaction: Transaction, in viewController: ActivitiesViewController) + func didPressTransaction(transaction: TransactionInstance, in viewController: ActivitiesViewController) } class ActivitiesViewController: UIViewController { @@ -143,6 +143,7 @@ class ActivitiesViewController: UIViewController { return nil } } + let t = Activity.AssignedToken(tokenObject: token) let activityName: String if wallet.sameContract(as: transactionRow.from) { @@ -201,7 +202,7 @@ class ActivitiesViewController: UIViewController { //We only use this ID for refreshing the display of specific activity, since the display for ETH send/receives don't ever need to be refreshed, just need a number that don't clash with other activities id: transactionRow.blockNumber + 10000000, rowType: rowType, - tokenObject: token, + tokenObject: t, server: transactionRow.server, name: activityName, eventName: activityName, @@ -301,16 +302,16 @@ extension ActivitiesViewController: UISearchResultsUpdating { //At least on iOS 13 beta on a device. updateSearchResults(for:) is called when we set `searchController.isActive = false` to dismiss search (because user tapped on a filter), but the value of `searchController.isActive` remains `false` during the call, hence the async. //This behavior is not observed in iOS 12, simulator public func updateSearchResults(for searchController: UISearchController) { - DispatchQueue.main.async { - self.processSearchWithKeywords() - } + processSearchWithKeywords() } private func processSearchWithKeywords() { let keyword = searchController.searchBar.text viewModel.filter(.keyword(keyword)) + tableView.reloadData() } + } extension ActivitiesViewController { diff --git a/AlphaWallet/Activities/ViewModels/ActivitiesViewModel.swift b/AlphaWallet/Activities/ViewModels/ActivitiesViewModel.swift index 5ea664136..8b50cedb8 100644 --- a/AlphaWallet/Activities/ViewModels/ActivitiesViewModel.swift +++ b/AlphaWallet/Activities/ViewModels/ActivitiesViewModel.swift @@ -18,12 +18,12 @@ struct ActivitiesViewModel { private var filteredItems: [MappedToDateActivityOrTransaction] = [] private let tokensStorages: ServerDictionary - init(tokensStorages: ServerDictionary, activities: [ActivityOrTransactionRow] = []) { - items = ActivitiesViewModel.sorted(activities: activities) + init(tokensStorages: ServerDictionary, activities: [MappedToDateActivityOrTransaction] = []) { + items = activities self.tokensStorages = tokensStorages } - private static func sorted(activities: [ActivityOrTransactionRow]) -> [MappedToDateActivityOrTransaction] { + static func sorted(activities: [ActivityOrTransactionRow]) -> [MappedToDateActivityOrTransaction] { //Uses NSMutableArray instead of Swift array for performance. Really slow when dealing with 10k events, which is hardly a big wallet var newItems: [String: NSMutableArray] = [:] for each in activities { @@ -68,7 +68,11 @@ struct ActivitiesViewModel { } }) }.sorted { (object1, object2) -> Bool in - ActivitiesViewModel.formatter.date(from: object1.date)! > ActivitiesViewModel.formatter.date(from: object2.date)! + //NOTE: Remove force unwrap to prevent crash + guard let date1 = ActivitiesViewModel.formatter.date(from: object1.date), let date2 = ActivitiesViewModel.formatter.date(from: object2.date) else { + return false + } + return date1 > date2 } } diff --git a/AlphaWallet/Core/Initializers/MigrationInitializerForOneChainPerDatabase.swift b/AlphaWallet/Core/Initializers/MigrationInitializerForOneChainPerDatabase.swift index 4b7c7b183..84f7c7b28 100644 --- a/AlphaWallet/Core/Initializers/MigrationInitializerForOneChainPerDatabase.swift +++ b/AlphaWallet/Core/Initializers/MigrationInitializerForOneChainPerDatabase.swift @@ -22,7 +22,9 @@ class MigrationInitializerForOneChainPerDatabase: Initializer { // swiftlint:disable function_body_length func perform() { config.schemaVersion = 53 - config.migrationBlock = { migration, oldSchemaVersion in + config.migrationBlock = { [weak self] migration, oldSchemaVersion in + guard let strongSelf = self else { return } + if oldSchemaVersion < 33 { migration.enumerateObjects(ofType: TokenObject.className()) { oldObject, newObject in guard let oldObject = oldObject else { return } @@ -57,7 +59,7 @@ class MigrationInitializerForOneChainPerDatabase: Initializer { guard let oldObject = oldObject else { return } guard let newObject = newObject else { return } if let contract = (oldObject["contract"] as? String).flatMap({ AlphaWallet.Address(uncheckedAgainstNullAddress: $0) }), let type = (oldObject["rawType"] as? String).flatMap({ TokenType(rawValue: $0) }) { - let tokenTypeName = XMLHandler(contract: contract, tokenType: type, assetDefinitionStore: self.assetDefinitionStore).getLabel(fallback: "") + let tokenTypeName = XMLHandler(contract: contract, tokenType: type, assetDefinitionStore: strongSelf.assetDefinitionStore).getLabel(fallback: "") if !tokenTypeName.isEmpty { newObject["name"] = "" } @@ -76,7 +78,7 @@ class MigrationInitializerForOneChainPerDatabase: Initializer { migration.deleteData(forType: Transaction.className()) } if oldSchemaVersion < 53 { - let chainId = self.server.chainID + let chainId = strongSelf.server.chainID migration.enumerateObjects(ofType: TokenObject.className()) { _, newObject in guard let newObject = newObject else { return } guard let contract = newObject["contract"] as? String else { diff --git a/AlphaWallet/Core/Types/AlphaWalletAddress.swift b/AlphaWallet/Core/Types/AlphaWalletAddress.swift index a1f832c76..268106f2c 100644 --- a/AlphaWallet/Core/Types/AlphaWalletAddress.swift +++ b/AlphaWallet/Core/Types/AlphaWalletAddress.swift @@ -8,11 +8,35 @@ import WalletCore ///Use an enum as a namespace until Swift has proper namespaces public enum AlphaWallet {} +extension AlphaWallet.Address { + private class TheadSafeAddressCache { + private var cache: [String: AlphaWallet.Address] = .init() + private let accessQueue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(key: String) -> AlphaWallet.Address? { + get { + var element: AlphaWallet.Address? + accessQueue.sync { + element = cache[key] + } + + return element + } + set { + accessQueue.async(flags: .barrier) { + self.cache[key] = newValue + } + } + } + } +} + //TODO move this to a standard alone internal Pod with 0 external dependencies so main app and TokenScript can use it? extension AlphaWallet { public enum Address: Hashable, Codable { //Computing EIP55 is really slow. Cache needed when we need to create many addresses, like parsing a whole lot of Ethereum event logs - private static var cache: [String: Address] = .init() + //there is cases when cache accessing from different treads, fro this case we need to use sync access for it + private static var cache: TheadSafeAddressCache = .init() case ethereumAddress(eip55String: String) @@ -36,10 +60,16 @@ extension AlphaWallet { //TODO not sure if we should keep this init?(uncheckedAgainstNullAddress string: String) { + if let value = Self.cache[string] { + self = value + return + } + let string = string.add0x guard string.count == 42 else { return nil } guard let address = TrustKeystore.Address(uncheckedAgainstNullAddress: string) else { return nil } self = .ethereumAddress(eip55String: address.eip55String) + Self.cache[string] = self } init(fromPrivateKey privateKey: Data) { diff --git a/AlphaWallet/Core/Types/AlphaWalletAddressExtension.swift b/AlphaWallet/Core/Types/AlphaWalletAddressExtension.swift index 9a587571d..15140ca84 100644 --- a/AlphaWallet/Core/Types/AlphaWalletAddressExtension.swift +++ b/AlphaWallet/Core/Types/AlphaWalletAddressExtension.swift @@ -22,8 +22,13 @@ extension AlphaWallet.Address { extension EthereumAddress { init(address: AlphaWallet.Address) { //EthereumAddress(Data) is much faster than EthereumAddress(String). This is significant because we can make a few hundred calls - let data = Data.fromHex(address.eip55String)! - self.init(data)! +// let data = Data.fromHex(address.eip55String)! +// self.init(data)! + + //During testing we found that EthereumAddress(address.eip55String) is faster then self.init(data)! + //approx time is 0.000980973243713379 while with using self.init(data)! is 2.8967857360839844e-05 seconds. + + self.init(address.eip55String)! } } diff --git a/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift b/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift index ecf769320..70c3b3c26 100644 --- a/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift +++ b/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift @@ -52,22 +52,25 @@ struct RawTransaction: Decodable { let operationsLocalized: [LocalizedOperation]? } -extension Transaction { - static func from(transaction: RawTransaction, tokensStorage: TokensDataStore) -> Promise { +extension TransactionInstance { + static func from(transaction: RawTransaction, tokensStorage: TokensDataStore) -> Promise { guard let from = AlphaWallet.Address(string: transaction.from) else { return Promise.value(nil) } + let state: TransactionState = { if transaction.error?.isEmpty == false || transaction.isError == "1" { return .error } return .completed }() + let to = AlphaWallet.Address(string: transaction.to)?.eip55String ?? transaction.to + return firstly { createOperationForTokenTransfer(forTransaction: transaction, tokensStorage: tokensStorage) - }.then { operations -> Promise in - let result = Transaction( + }.then { operations -> Promise in + let result = TransactionInstance( id: transaction.hash, server: tokensStorage.server, blockNumber: Int(transaction.blockNumber)!, @@ -84,11 +87,12 @@ extension Transaction { state: state, isErc20Interaction: false ) + return .value(result) } } - static private func createOperationForTokenTransfer(forTransaction transaction: RawTransaction, tokensStorage: TokensDataStore) -> Promise<[LocalizedOperationObject]> { + static private func createOperationForTokenTransfer(forTransaction transaction: RawTransaction, tokensStorage: TokensDataStore) -> Promise<[LocalizedOperationObjectInstance]> { guard transaction.input != "0x" else { return Promise.value([]) } @@ -105,21 +109,23 @@ extension Transaction { let amount = BigInt(amount1, radix: 16) //Extract the address and strip the first 12 (x2 = 24) characters of 0s let to = "0x\(transaction.input[transaction.input.index(transaction.input.startIndex, offsetBy: 10 + 24).. Promise<[LocalizedOperationObject]> in + }.then { name, symbol, decimals, tokenType -> Promise<[LocalizedOperationObjectInstance]> in let operationType = mapTokenTypeToTransferOperationType(tokenType) - let result = LocalizedOperationObject(from: transaction.from, to: to, contract: contract, type: operationType.rawValue, value: String(amount), symbol: symbol, name: name, decimals: Int(decimals)) + let result = LocalizedOperationObjectInstance(from: transaction.from, to: to, contract: contract, type: operationType.rawValue, value: String(amount), symbol: symbol, name: name, decimals: Int(decimals)) return .value([result]) } } diff --git a/AlphaWallet/InCoordinator.swift b/AlphaWallet/InCoordinator.swift index fa8318e9b..49e6596d6 100644 --- a/AlphaWallet/InCoordinator.swift +++ b/AlphaWallet/InCoordinator.swift @@ -310,6 +310,7 @@ class InCoordinator: NSObject, Coordinator { setupTokenDataStores() setupNativeCryptoCurrencyPrices() setupNativeCryptoCurrencyBalances() + setupEventsStorages() setupTransactionsStorages() setupEtherBalances() setupWalletSessions() @@ -319,6 +320,12 @@ class InCoordinator: NSObject, Coordinator { setUpEventSourceCoordinatorForActivities() } + private func setupEventsStorages() { + let realm = self.realm(forAccount: wallet) + + eventsDataStore = EventsDataStore(realm: realm) + eventsActivityDataStore = EventsActivityDataStore(realm: realm) + } private func setupNativeCryptoCurrencyPrices() { nativeCryptoCurrencyPrices = createEtherPricesSubscribablesForAllChains() } @@ -857,7 +864,7 @@ extension InCoordinator: TokensCoordinatorDelegate { showPaymentFlow(for: type, server: server) } - func didTap(transaction: Transaction, inViewController viewController: UIViewController, in coordinator: TokensCoordinator) { + func didTap(transaction: TransactionInstance, inViewController viewController: UIViewController, in coordinator: TokensCoordinator) { if transaction.localizedOperations.count > 1 { transactionCoordinator?.showTransaction(.group(transaction), inViewController: viewController) } else { @@ -940,7 +947,8 @@ extension InCoordinator: EventSourceCoordinatorForActivitiesDelegate { } extension InCoordinator: ActivitiesCoordinatorDelegate { - func didPressTransaction(transaction: Transaction, in viewController: ActivitiesViewController) { + + func didPressTransaction(transaction: TransactionInstance, in viewController: ActivitiesViewController) { if transaction.localizedOperations.count > 1 { transactionCoordinator?.showTransaction(.group(transaction), inViewController: viewController) } else { @@ -1001,4 +1009,4 @@ extension InCoordinator { private func logTappedSwap(service: SwapTokenURLProviderType) { analyticsCoordinator.log(navigation: Analytics.Navigation.tokenSwap, properties: [Analytics.Properties.name.rawValue: service.analyticsName]) } -} \ No newline at end of file +} diff --git a/AlphaWallet/Settings/Types/RPCServers.swift b/AlphaWallet/Settings/Types/RPCServers.swift index e9f439042..c6b07477e 100644 --- a/AlphaWallet/Settings/Types/RPCServers.swift +++ b/AlphaWallet/Settings/Types/RPCServers.swift @@ -100,7 +100,7 @@ enum RPCServer: Hashable, CaseIterable { case .custom: return nil } } - + //TODO fix up all the networks var getEtherscanURLERC20Events: String? { switch self { diff --git a/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinator.swift b/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinator.swift index a2c675c68..98f6fbaba 100644 --- a/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinator.swift +++ b/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinator.swift @@ -14,6 +14,7 @@ class EventSourceCoordinator { private let eventsDataStore: EventsDataStoreProtocol private var isFetching = false private var rateLimitedUpdater: RateLimiter? + private let queue = DispatchQueue(label: "com.eventSourceCoordinator.updateQueue") init(wallet: Wallet, config: Config, tokensStorages: ServerDictionary, assetDefinitionStore: AssetDefinitionStore, eventsDataStore: EventsDataStoreProtocol) { self.wallet = wallet @@ -32,36 +33,40 @@ class EventSourceCoordinator { for each in xmlHandler.attributesWithEventSource { guard let eventOrigin = each.eventOrigin else { continue } let tokenHolders = TokenAdaptor(token: token, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: wallet, sourceFromEvents: false) + for eachTokenHolder in tokenHolders { guard let tokenId = eachTokenHolder.tokenIds.first else { continue } let promise = fetchEvents(forTokenId: tokenId, token: token, eventOrigin: eventOrigin) fetchPromises.append(promise) } } + return fetchPromises } func fetchEthereumEvents() { if rateLimitedUpdater == nil { rateLimitedUpdater = RateLimiter(name: "Poll Ethereum events for instances", limit: 15, autoRun: true) { [weak self] in - self?.fetchEthereumEventsImpl() + guard let strongSelf = self else { return } + + strongSelf.queue.async { + strongSelf.fetchEthereumEventsImpl() + } } } else { rateLimitedUpdater?.run() } } - func fetchEthereumEventsImpl() { + private func fetchEthereumEventsImpl() { guard !isFetching else { return } isFetching = true + let tokensStoragesForEnabledServers = config.enabledServers.map { tokensStorages[$0] } - var fetchPromises = [Promise]() - for eachTokenStorage in tokensStoragesForEnabledServers { - for eachToken in eachTokenStorage.enabledObject { - let promises = fetchEventsByTokenId(forToken: eachToken) - fetchPromises.append(contentsOf: promises) - } + let fetchPromises = tokensStoragesForEnabledServers.flatMap { + $0.enabledObject.flatMap { fetchEventsByTokenId(forToken: $0) } } + when(resolved: fetchPromises).done { _ in self.isFetching = false } @@ -69,38 +74,44 @@ class EventSourceCoordinator { private func fetchEvents(forTokenId tokenId: TokenId, token: TokenObject, eventOrigin: EventOrigin) -> Promise { let (filterName, filterValue) = eventOrigin.eventFilter - let filterParam: [(filter: [EventFilterable], textEquivalent: String)?] = eventOrigin.parameters + let filterParam = eventOrigin.parameters .filter { $0.isIndexed } .map { self.formFilterFrom(fromParameter: $0, tokenId: tokenId, filterName: filterName, filterValue: filterValue) } - let fromBlock: EventFilter.Block - let oldEvents = eventsDataStore.getMatchingEventsSortedByBlockNumber(forContract: eventOrigin.contract, tokenContract: token.contractAddress, server: token.server, eventName: eventOrigin.eventName) - if let newestEvent = oldEvents.last { - fromBlock = .blockNumber(UInt64(newestEvent.blockNumber + 1)) - } else { - fromBlock = .blockNumber(0) - } - let eventFilter = EventFilter(fromBlock: fromBlock, toBlock: .latest, addresses: [EthereumAddress(address: eventOrigin.contract)], parameterFilters: filterParam.map { $0?.filter }) - return firstly { - getEventLogs(withServer: token.server, contract: eventOrigin.contract, eventName: eventOrigin.eventName, abiString: eventOrigin.eventAbiString, filter: eventFilter) - }.map { result -> [EventInstance] in - result.compactMap { self.convertEventToDatabaseObject($0, filterParam: filterParam, eventOrigin: eventOrigin, token: token, server: token.server) } - }.map { events in - self.eventsDataStore.add(events: events, forTokenContract: token.contractAddress) - } + let contractAddress = token.contractAddress + let tokenServer = token.server + + return eventsDataStore.getLastMatchingEventSortedByBlockNumber(forContract: eventOrigin.contract, tokenContract: contractAddress, server: tokenServer, eventName: eventOrigin.eventName).map(on: queue, { oldEvent -> EventFilter.Block in + if let newestEvent = oldEvent { + return .blockNumber(UInt64(newestEvent.blockNumber + 1)) + } else { + return .blockNumber(0) + } + }).map(on: queue, { fromBlock -> EventFilter in + EventFilter(fromBlock: fromBlock, toBlock: .latest, addresses: [EthereumAddress(address: eventOrigin.contract)], parameterFilters: filterParam.map { $0?.filter }) + }).then(on: queue, { eventFilter in + getEventLogs(withServer: tokenServer, contract: eventOrigin.contract, eventName: eventOrigin.eventName, abiString: eventOrigin.eventAbiString, filter: eventFilter, queue: self.queue) + }).map(on: queue, { result -> [EventInstanceValue] in + result.compactMap { + self.convertEventToDatabaseObject($0, filterParam: filterParam, eventOrigin: eventOrigin, contractAddress: contractAddress, server: tokenServer) + } + }).then(on: queue, { events -> Promise in + return self.eventsDataStore.add(events: events, forTokenContract: contractAddress) + }) } - private func convertEventToDatabaseObject(_ event: EventParserResultProtocol, filterParam: [(filter: [EventFilterable], textEquivalent: String)?], eventOrigin: EventOrigin, token: TokenObject, server: RPCServer) -> EventInstance? { + private func convertEventToDatabaseObject(_ event: EventParserResultProtocol, filterParam: [(filter: [EventFilterable], textEquivalent: String)?], eventOrigin: EventOrigin, contractAddress: AlphaWallet.Address, server: RPCServer) -> EventInstanceValue? { guard let blockNumber = event.eventLog?.blockNumber else { return nil } guard let logIndex = event.eventLog?.logIndex else { return nil } - let decodedResult = self.convertToJsonCompatible(dictionary: event.decodedResult) + let decodedResult = Self.convertToJsonCompatible(dictionary: event.decodedResult) guard let json = decodedResult.jsonString else { return nil } //TODO when TokenScript schema allows it, support more than 1 filter let filterTextEquivalent = filterParam.compactMap({ $0?.textEquivalent }).first let filterText = filterTextEquivalent ?? "\(eventOrigin.eventFilter.name)=\(eventOrigin.eventFilter.value)" - return EventInstance(contract: eventOrigin.contract, tokenContract: token.contractAddress, server: server, eventName: eventOrigin.eventName, blockNumber: Int(blockNumber), logIndex: Int(logIndex), filter: filterText, json: json) + + return EventInstanceValue(contract: eventOrigin.contract, tokenContract: contractAddress, server: server, eventName: eventOrigin.eventName, blockNumber: Int(blockNumber), logIndex: Int(logIndex), filter: filterText, json: json) } - private func convertToJsonCompatible(dictionary: [String: Any]) -> [String: Any] { + private static func convertToJsonCompatible(dictionary: [String: Any]) -> [String: Any] { Dictionary(uniqueKeysWithValues: dictionary.compactMap { key, value -> (String, Any)? in switch value { case let address as EthereumAddress: diff --git a/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinatorForActivities.swift b/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinatorForActivities.swift index e974d324c..60f7de545 100644 --- a/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinatorForActivities.swift +++ b/AlphaWallet/TokenScriptClient/Coordinators/EventSourceCoordinatorForActivities.swift @@ -17,8 +17,9 @@ class EventSourceCoordinatorForActivities { private let eventsDataStore: EventsActivityDataStoreProtocol private var isFetching = false private var rateLimitedUpdater: RateLimiter? - + private let queue = DispatchQueue(label: "com.EventSourceCoordinatorForActivities.updateQueue") weak var delegate: EventSourceCoordinatorForActivitiesDelegate? + private let timestampCoordinator = GetBlockTimestampCoordinator() init(wallet: Wallet, config: Config, tokensStorages: ServerDictionary, assetDefinitionStore: AssetDefinitionStore, eventsDataStore: EventsActivityDataStoreProtocol) { self.wallet = wallet @@ -29,92 +30,132 @@ class EventSourceCoordinatorForActivities { } func fetchEvents(forToken token: TokenObject) -> [Promise] { - let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) - guard xmlHandler.hasAssetDefinition else { return .init() } - var fetchPromises = [Promise]() - for each in xmlHandler.activityCards { - guard let promise = fetchEvents(token: token, card: each) else { continue } - fetchPromises.append(promise) + let xmlHandler = XMLHandler(contract: token.contractAddress, tokenType: token.type, assetDefinitionStore: assetDefinitionStore) + guard xmlHandler.hasAssetDefinition else { return [] } + + return xmlHandler.activityCards.compactMap { + self.fetchEvents(tokenContract: token.contractAddress, server: token.server, card: $0) + } + } + + func fetchEvents(contract: AlphaWallet.Address, tokenType: TokenType, rpcServer: RPCServer) -> [Promise] { + let xmlHandler = XMLHandler(contract: contract, tokenType: tokenType, assetDefinitionStore: assetDefinitionStore) + guard xmlHandler.hasAssetDefinition else { return [] } + + return xmlHandler.activityCards.compactMap { + fetchEvents(tokenContract: contract, server: rpcServer, card: $0) } - return fetchPromises } func fetchEthereumEvents() { if rateLimitedUpdater == nil { rateLimitedUpdater = RateLimiter(name: "Poll Ethereum events for Activities", limit: 60, autoRun: true) { [weak self] in - self?.fetchEthereumEventsImpl() + guard let strongSelf = self else { return } + + strongSelf.queue.async { + strongSelf.fetchEthereumEventsImpl() + } } } else { rateLimitedUpdater?.run() } } - func fetchEthereumEventsImpl() { + private func fetchEthereumEventsImpl() { guard !isFetching else { return } isFetching = true - let tokensStoragesForEnabledServers = config.enabledServers.map { tokensStorages[$0] } - var fetchPromises = [Promise]() - for eachTokenStorage in tokensStoragesForEnabledServers { - for eachToken in eachTokenStorage.enabledObject { - let promises = fetchEvents(forToken: eachToken) - fetchPromises.append(contentsOf: promises) + + let promises = firstly { + tokensForEnabledRPCServers() + }.map(on: queue, { data -> [Promise] in + data.flatMap { data in + self.fetchEvents(contract: data.contract, tokenType: data.tokenType, rpcServer: data.server) } - } - when(resolved: fetchPromises).done { _ in + }) + + when(resolved: promises).done(on: queue, { _ in self.isFetching = false + }) + } + + typealias EnabledTokenAddreses = [(contract: AlphaWallet.Address, tokenType: TokenType, server: RPCServer)] + private func tokensForEnabledRPCServers() -> Promise { + return Promise { seal in + let tokensStoragesForEnabledServers = self.config.enabledServers.map { self.tokensStorages[$0] } + + let data = tokensStoragesForEnabledServers.flatMap { + $0.enabledObject + }.compactMap { + (contract: $0.contractAddress, tokenType: $0.type, server: $0.server) + } + + seal.fulfill(data) } } - private func fetchEvents(token: TokenObject, card: TokenScriptCard) -> Promise? { + private func fetchEvents(tokenContract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard) -> Promise? { let eventOrigin = card.eventOrigin + let (filterName, filterValue) = eventOrigin.eventFilter - let filterParam: [(filter: [EventFilterable], textEquivalent: String)?] = eventOrigin.parameters - .filter { $0.isIndexed } - .map { self.formFilterFrom(fromParameter: $0, filterName: filterName, filterValue: filterValue) } - let fromBlock: EventFilter.Block - let oldEvents = eventsDataStore.getMatchingEventsSortedByBlockNumber(forContract: eventOrigin.contract, tokenContract: token.contractAddress, server: token.server, eventName: eventOrigin.eventName) - //TODO have to start from 0 if the TokenScript file changes? - if let newestEvent = oldEvents.last { - fromBlock = .blockNumber(UInt64(newestEvent.blockNumber + 1)) - } else { - fromBlock = .blockNumber(0) + let filterParam = eventOrigin.parameters.filter { + $0.isIndexed + }.map { + self.formFilterFrom(fromParameter: $0, filterName: filterName, filterValue: filterValue) } - let eventFilter = EventFilter(fromBlock: fromBlock, toBlock: .latest, addresses: [EthereumAddress(address: eventOrigin.contract)], parameterFilters: filterParam.map { $0?.filter }) + if filterParam.allSatisfy({ $0 == nil }) { //TODO log to console as diagnostic NSLog("TokenScript filter parameters for Activity \"\(card.name)\" are all nil. Ignoring this Activity. \(filterParam.map { $0?.filter })") return nil } - return firstly { - getEventLogs(withServer: token.server, contract: eventOrigin.contract, eventName: eventOrigin.eventName, abiString: eventOrigin.eventAbiString, filter: eventFilter) - }.thenMap { event -> Promise<(EventParserResultProtocol, Date)?> in + + return eventsDataStore.getMatchingEventsSortedByBlockNumber(forContract: eventOrigin.contract, tokenContract: tokenContract, server: server, eventName: eventOrigin.eventName).map(on: queue, { oldEvent -> EventFilter.Block in + if let newestEvent = oldEvent { + return .blockNumber(UInt64(newestEvent.blockNumber + 1)) + } else { + return .blockNumber(0) + } + }).map(on: queue, { fromBlock -> EventFilter in + let parameterFilters = filterParam.map { $0?.filter } + let addresses = [EthereumAddress(address: eventOrigin.contract)] + + return EventFilter(fromBlock: fromBlock, toBlock: .latest, addresses: addresses, parameterFilters: parameterFilters) + }).then(on: queue, { eventFilter in + getEventLogs(withServer: server, contract: eventOrigin.contract, eventName: eventOrigin.eventName, abiString: eventOrigin.eventAbiString, filter: eventFilter, queue: self.queue) + }).thenMap(on: queue, { event -> Promise<(EventParserResultProtocol, Date)?> in guard let blockNumber = event.eventLog?.blockNumber else { return .value(nil) } - return GetBlockTimestampCoordinator().getBlockTimestamp(blockNumber, onServer: token.server).map { date in (event, date) } - }.compactMapValues { + return self.timestampCoordinator.getBlockTimestamp(blockNumber, onServer: server).map(on: self.queue, { date in (event, date) }) + }).compactMapValues(on: queue, { $0 - }.compactMapValues { event, date in - self.convertEventToDatabaseObject(event, date: date, filterParam: filterParam, eventOrigin: eventOrigin, token: token, server: token.server) - }.map { events in - self.eventsDataStore.add(events: events, forTokenContract: token.contractAddress) + }).compactMapValues(on: queue, { event, date in + return self.convertEventToDatabaseObject(event, date: date, filterParam: filterParam, eventOrigin: eventOrigin, tokenContract: tokenContract, server: server) + }).then(on: queue, { events -> Promise in + return self.eventsDataStore.add(events: events, forTokenContract: tokenContract).map { _ -> Bool in + !events.isEmpty + } + }).map(on: queue, { shouldNotify in + guard shouldNotify else { return } + self.delegate?.didUpdate(inCoordinator: self) - } + }) } - private func convertEventToDatabaseObject(_ event: EventParserResultProtocol, date: Date, filterParam: [(filter: [EventFilterable], textEquivalent: String)?], eventOrigin: EventOrigin, token: TokenObject, server: RPCServer) -> EventActivity? { + private func convertEventToDatabaseObject(_ event: EventParserResultProtocol, date: Date, filterParam: [(filter: [EventFilterable], textEquivalent: String)?], eventOrigin: EventOrigin, tokenContract: AlphaWallet.Address, server: RPCServer) -> EventActivityInstance? { guard let blockNumber = event.eventLog?.blockNumber else { return nil } guard let logIndex = event.eventLog?.logIndex else { return nil } guard let transactionHash = event.eventLog?.transactionHash else { return nil } guard let transactionIndex = event.eventLog?.transactionIndex else { return nil } let transactionId = transactionHash.hexEncoded - let decodedResult = self.convertToJsonCompatible(dictionary: event.decodedResult) + let decodedResult = Self.convertToJsonCompatible(dictionary: event.decodedResult) guard let json = decodedResult.jsonString else { return nil } //TODO when TokenScript schema allows it, support more than 1 filter let filterTextEquivalent = filterParam.compactMap({ $0?.textEquivalent }).first let filterText = filterTextEquivalent ?? "\(eventOrigin.eventFilter.name)=\(eventOrigin.eventFilter.value)" - return EventActivity(contract: eventOrigin.contract, tokenContract: token.contractAddress, server: server, date: date, eventName: eventOrigin.eventName, blockNumber: Int(blockNumber), transactionId: transactionId, transactionIndex: Int(transactionIndex), logIndex: Int(logIndex), filter: filterText, json: json) + + return EventActivityInstance(contract: eventOrigin.contract, tokenContract: tokenContract, server: server, date: date, eventName: eventOrigin.eventName, blockNumber: Int(blockNumber), transactionId: transactionId, transactionIndex: Int(transactionIndex), logIndex: Int(logIndex), filter: filterText, json: json) } - private func convertToJsonCompatible(dictionary: [String: Any]) -> [String: Any] { + private static func convertToJsonCompatible(dictionary: [String: Any]) -> [String: Any] { Dictionary(uniqueKeysWithValues: dictionary.compactMap { key, value -> (String, Any)? in switch value { case let address as EthereumAddress: diff --git a/AlphaWallet/TokenScriptClient/Models/Activity.swift b/AlphaWallet/TokenScriptClient/Models/Activity.swift index d60641c9a..c4e81d925 100644 --- a/AlphaWallet/TokenScriptClient/Models/Activity.swift +++ b/AlphaWallet/TokenScriptClient/Models/Activity.swift @@ -28,7 +28,7 @@ struct Activity { let id: Int let rowType: ActivityRowType //TODO safe to have TokenObject here? Maybe a struct is better - let tokenObject: TokenObject + let tokenObject: AssignedToken let server: RPCServer let name: String let eventName: String @@ -43,6 +43,25 @@ struct Activity { let isBaseCard: Bool let state: State + init(id: Int, rowType: ActivityRowType, tokenObject: AssignedToken, server: RPCServer, name: String, eventName: String, blockNumber: Int, transactionId: String, transactionIndex: Int, logIndex: Int, date: Date, values: (token: [AttributeId: AssetInternalValue], card: [AttributeId: AssetInternalValue]), view: (html: String, style: String), itemView: (html: String, style: String), isBaseCard: Bool, state: State) { + self.id = id + self.tokenObject = tokenObject + self.server = server + self.name = name + self.eventName = eventName + self.blockNumber = blockNumber + self.transactionId = transactionId + self.transactionIndex = transactionIndex + self.logIndex = logIndex + self.date = date + self.values = values + self.view = view + self.itemView = itemView + self.isBaseCard = isBaseCard + self.state = state + self.rowType = rowType + } + var viewHtml: (html: String, hash: Int) { let hash = "\(view.style)\(view.html)".hashForCachingHeight return (html: wrapWithHtmlViewport(html: view.html, style: view.style, forTokenId: .init(id)), hash: hash) @@ -102,4 +121,4 @@ struct Activity { return .none } } -} \ No newline at end of file +} diff --git a/AlphaWallet/TokenScriptClient/Models/ActivityOrTransaction.swift b/AlphaWallet/TokenScriptClient/Models/ActivityOrTransaction.swift new file mode 100644 index 000000000..2af9c2ec7 --- /dev/null +++ b/AlphaWallet/TokenScriptClient/Models/ActivityOrTransaction.swift @@ -0,0 +1,71 @@ +// Copyright © 2020 Stormbird PTE. LTD. + +import Foundation + +enum ActivityOrTransaction { + case activity(Activity) + case transaction(TransactionInstance) + + var activityName: String? { + switch self { + case .activity(let activity): + return activity.name + case .transaction: + return nil + } + } + + var date: Date { + switch self { + case .activity(let activity): + return activity.date + case .transaction(let transaction): + return transaction.date + } + } + + var blockNumber: Int { + switch self { + case .activity(let activity): + return activity.blockNumber + case .transaction(let transaction): + return transaction.blockNumber + } + } + + var transactionIndex: Int { + switch self { + case .activity(let activity): + return activity.transactionIndex + case .transaction(let transaction): + return transaction.transactionIndex + } + } + + func getTokenSymbol(fromTokensStorages tokensStorages: ServerDictionary) -> String? { + switch self { + case .activity(let activity): + return activity.tokenObject.symbol + case .transaction(let transaction): + return getSymbol(fromTransaction: transaction, tokensStorages: tokensStorages) + } + } + + private func getSymbol(fromTransaction transaction: TransactionInstance, tokensStorages: ServerDictionary) -> String? { + if transaction.operation == nil { + let token = TokensDataStore.etherToken(forServer: transaction.server) + return token.symbol + } else { + switch (transaction.state, transaction.operation?.operationType) { + case (.pending, .erc20TokenTransfer), (.pending, .erc721TokenTransfer), (.pending, .erc875TokenTransfer): + let token = transaction.operation?.contractAddress.flatMap { tokensStorages[transaction.server].tokenThreadSafe(forContract: $0) } + return token?.symbol + //Explicitly listing out combinations so future changes to enums will be caught by compiler + case (.pending, .nativeCurrencyTokenTransfer), (.pending, .unknown), (.pending, nil): + return nil + case (.unknown, _), (.error, _), (.failed, _), (.completed, _): + return nil + } + } + } +} diff --git a/AlphaWallet/TokenScriptClient/Models/AssetAttributeValues.swift b/AlphaWallet/TokenScriptClient/Models/AssetAttributeValues.swift index e9b6b6d66..1374a9f13 100644 --- a/AlphaWallet/TokenScriptClient/Models/AssetAttributeValues.swift +++ b/AlphaWallet/TokenScriptClient/Models/AssetAttributeValues.swift @@ -29,10 +29,10 @@ class AssetAttributeValues { if !subscribedAttributes.contains(where: { $0 === subscribable }) { subscribedAttributes.append(subscribable) subscribable.subscribe { [weak self] value in - guard let strongSelf = self, let value = value else { return } + guard let stongSelf = self, let value = value else { return } - strongSelf.resolvedAttributeValues[name] = value - block(strongSelf.resolvedAttributeValues) + stongSelf.resolvedAttributeValues[name] = value + block(stongSelf.resolvedAttributeValues) } } } diff --git a/AlphaWallet/TokenScriptClient/Models/EventsActivityDataStore.swift b/AlphaWallet/TokenScriptClient/Models/EventsActivityDataStore.swift index 702ce3077..48f838ab3 100644 --- a/AlphaWallet/TokenScriptClient/Models/EventsActivityDataStore.swift +++ b/AlphaWallet/TokenScriptClient/Models/EventsActivityDataStore.swift @@ -2,12 +2,12 @@ import Foundation import RealmSwift +import PromiseKit protocol EventsActivityDataStoreProtocol { - func add(events: [EventActivity], forTokenContract contract: AlphaWallet.Address) - func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> [EventActivity] - func getEvents(forContract contract: AlphaWallet.Address, forEventName eventName: String, filter: String, server: RPCServer) -> [EventActivity] func getRecentEvents() -> [EventActivity] + func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> Promise + func add(events: [EventActivityInstance], forTokenContract contract: AlphaWallet.Address) -> Promise } class EventsActivityDataStore: EventsActivityDataStoreProtocol { @@ -20,13 +20,20 @@ class EventsActivityDataStore: EventsActivityDataStoreProtocol { self.realm = realm } - func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> [EventActivity] { - Array(realm.objects(EventActivity.self) + func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> Promise { + + return Promise { seal in + let objects = realm.threadSafe.objects(EventActivity.self) .filter("contract = '\(contract.eip55String)'") .filter("tokenContract = '\(tokenContract.eip55String)'") .filter("chainId = \(server.chainID)") .filter("eventName = '\(eventName)'") - .sorted(byKeyPath: "blockNumber")) + .sorted(byKeyPath: "blockNumber") + .last + .map { EventActivityInstance(event: $0) } + + seal.fulfill(objects) + } } func getEvents(forContract contract: AlphaWallet.Address, forEventName eventName: String, filter: String, server: RPCServer) -> [EventActivity] { @@ -38,7 +45,7 @@ class EventsActivityDataStore: EventsActivityDataStoreProtocol { } func getRecentEvents() -> [EventActivity] { - return Array(realm.objects(EventActivity.self) + return Array(realm.threadSafe.objects(EventActivity.self) .sorted(byKeyPath: "date", ascending: false) .prefix(Self.numberOfActivitiesToUse) ) @@ -50,12 +57,24 @@ class EventsActivityDataStore: EventsActivityDataStoreProtocol { } } - func add(events: [EventActivity], forTokenContract contract: AlphaWallet.Address) { - guard !events.isEmpty else { return } - try! realm.write { - for each in events { - realm.add(each, update: .all) + func add(events: [EventActivityInstance], forTokenContract contract: AlphaWallet.Address) -> Promise { + if events.isEmpty { + return .value(()) + } else { + return Promise { seal in + let eventsToSave = events.map { EventActivity(value: $0) } + + do { + let realm = self.realm.threadSafe + try realm.write { + realm.add(eventsToSave, update: .all) + + seal.fulfill(()) + } + } catch { + seal.reject(error) + } } } } -} +} diff --git a/AlphaWallet/TokenScriptClient/Models/EventsDataStore.swift b/AlphaWallet/TokenScriptClient/Models/EventsDataStore.swift index 3cd335ca3..46a464023 100644 --- a/AlphaWallet/TokenScriptClient/Models/EventsDataStore.swift +++ b/AlphaWallet/TokenScriptClient/Models/EventsDataStore.swift @@ -2,12 +2,13 @@ import Foundation import RealmSwift +import PromiseKit protocol EventsDataStoreProtocol { - func add(events: [EventInstance], forTokenContract contract: AlphaWallet.Address) + func getLastMatchingEventSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> Promise + func add(events: [EventInstanceValue], forTokenContract contract: AlphaWallet.Address) -> Promise func deleteEvents(forTokenContract contract: AlphaWallet.Address) func getMatchingEvents(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, filterName: String, filterValue: String) -> [EventInstance] - func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> [EventInstance] func subscribe(_ subscribe: @escaping (_ contract: AlphaWallet.Address) -> Void) } @@ -36,16 +37,7 @@ class EventsDataStore: EventsDataStoreProtocol { .filter("eventName = '\(eventName)'") //Filter stored as string, so we do a string comparison .filter("filter = '\(filterName)=\(filterValue)'")) - } - - func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> [EventInstance] { - Array(realm.objects(EventInstance.self) - .filter("contract = '\(contract.eip55String)'") - .filter("tokenContract = '\(tokenContract.eip55String)'") - .filter("chainId = \(server.chainID)") - .filter("eventName = '\(eventName)'") - .sorted(byKeyPath: "blockNumber")) - } + } func deleteEvents(forTokenContract contract: AlphaWallet.Address) { let events = getEvents(forTokenContract: contract) @@ -63,13 +55,37 @@ class EventsDataStore: EventsDataStoreProtocol { } } - func add(events: [EventInstance], forTokenContract contract: AlphaWallet.Address) { - guard !events.isEmpty else { return } - try! realm.write { - for each in events { - realm.add(each, update: .all) + func getLastMatchingEventSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> Promise { + return Promise { seal in + let event = Array(realm.threadSafe.objects(EventInstance.self) + .filter("contract = '\(contract.eip55String)'") + .filter("tokenContract = '\(tokenContract.eip55String)'") + .filter("chainId = \(server.chainID)") + .filter("eventName = '\(eventName)'") + .sorted(byKeyPath: "blockNumber")) + .last + + seal.fulfill(event) + } + } + + func add(events: [EventInstanceValue], forTokenContract contract: AlphaWallet.Address) -> Promise { + if events.isEmpty { + return .value(()) + } + + return Promise { seal in + do { + let realm = self.realm.threadSafe + try realm.write { + let eventsToSave = events.map { EventInstance(event: $0) } + realm.add(eventsToSave, update: .all) + + seal.fulfill(()) + } + } catch { + seal.reject(error) } } - triggerSubscribers(forContract: contract) } } diff --git a/AlphaWallet/TokenScriptClient/Models/XMLHandler.swift b/AlphaWallet/TokenScriptClient/Models/XMLHandler.swift index 0dd556b68..b93830338 100644 --- a/AlphaWallet/TokenScriptClient/Models/XMLHandler.swift +++ b/AlphaWallet/TokenScriptClient/Models/XMLHandler.swift @@ -666,12 +666,59 @@ private class PrivateXMLHandler { } // swiftlint:enable type_body_length + +private class ThreadSafeBaseXmlHandlersCache { + fileprivate var cache: [String: PrivateXMLHandler] = [:] + private let queue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(key: String) -> PrivateXMLHandler? { + get { + var element: PrivateXMLHandler? + queue.sync { + element = cache[key] + } + return element + } + set { + queue.async(flags: .barrier) { + self.cache[key] = newValue + } + } + } +} + +private class ThreadSafeXmlHandlersCache { + fileprivate var cache: [AlphaWallet.Address: PrivateXMLHandler] = [:] + private let queue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(key: AlphaWallet.Address) -> PrivateXMLHandler? { + get { + var element: PrivateXMLHandler? + queue.sync { + element = cache[key] + } + return element + } + set { + queue.async(flags: .barrier) { + self.cache[key] = newValue + } + } + } + + func removeAll() { + queue.async(flags: .barrier) { + self.cache.removeAll() + } + } +} + /// This class delegates all the functionality to a singleton of the actual XML parser. 1 for each contract. So we just parse the XML file 1 time only for each contract public class XMLHandler { //TODO not the best thing to have, especially because it's an optional static var callForAssetAttributeCoordinators: ServerDictionary? - fileprivate static var xmlHandlers: [AlphaWallet.Address: PrivateXMLHandler] = [:] - fileprivate static var baseXmlHandlers: [String: PrivateXMLHandler] = [:] + fileprivate static var xmlHandlers = ThreadSafeXmlHandlersCache() + fileprivate static var baseXmlHandlers = ThreadSafeBaseXmlHandlersCache() private let privateXMLHandler: PrivateXMLHandler private let baseXMLHandler: PrivateXMLHandler? diff --git a/AlphaWallet/Tokens/Coordinators/GetBlockTimestampCoordinator.swift b/AlphaWallet/Tokens/Coordinators/GetBlockTimestampCoordinator.swift index 03d304ec1..5438c4d6d 100644 --- a/AlphaWallet/Tokens/Coordinators/GetBlockTimestampCoordinator.swift +++ b/AlphaWallet/Tokens/Coordinators/GetBlockTimestampCoordinator.swift @@ -7,7 +7,7 @@ import web3swift class GetBlockTimestampCoordinator { //TODO persist? - private static var blockTimestampCache: [RPCServer: [BigUInt: Promise]] = .init() + private static var blockTimestampCache = TheadSafeBlockTimestampCache() func getBlockTimestamp(_ blockNumber: BigUInt, onServer server: RPCServer) -> Promise { var cacheForServer = Self.blockTimestampCache[server] ?? .init() @@ -33,7 +33,28 @@ class GetBlockTimestampCoordinator { cacheForServer[blockNumber] = promise Self.blockTimestampCache[server] = cacheForServer - return promise } + + private class TheadSafeBlockTimestampCache { + private var cache: [RPCServer: [BigUInt: Promise]] = .init() + private let accessQueue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(key: RPCServer) -> [BigUInt: Promise]? { + get { + var element: [BigUInt: Promise]? + accessQueue.sync { + element = cache[key] + } + + return element + } + set { + accessQueue.async(flags: .barrier) { + self.cache[key] = newValue + } + } + } + } } + diff --git a/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift b/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift index 1570f1b4d..837dde33f 100644 --- a/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift +++ b/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift @@ -8,73 +8,71 @@ import Alamofire import SwiftyJSON class GetContractInteractions { - func getErc20Interactions(contractAddress: AlphaWallet.Address? = nil, address: AlphaWallet.Address, server: RPCServer, startBlock: Int? = nil, completion: @escaping ([Transaction]) -> Void) { + + private let queue: DispatchQueue + + init(queue: DispatchQueue) { + self.queue = queue + } + + func getErc20Interactions(contractAddress: AlphaWallet.Address? = nil, address: AlphaWallet.Address, server: RPCServer, startBlock: Int? = nil, completion: @escaping ([TransactionInstance]) -> Void) { guard let etherscanURL = server.etherscanAPIURLForERC20TxList(for: address, startBlock: startBlock) else { return } - Alamofire.request(etherscanURL).validate().responseJSON { response in + + Alamofire.request(etherscanURL).validate().responseJSON(queue: queue, options: [], completionHandler: { response in switch response.result { case .success(let value): //Performance: process in background so UI don't have a chance of blocking if there's a long list of contracts - DispatchQueue.global().async { - let json = JSON(value) - let filteredResult: [(String, JSON)] - if let contractAddress = contractAddress { - //filter based on what contract you are after - filteredResult = json["result"].filter { - $0.1["contractAddress"].stringValue == contractAddress.eip55String.lowercased() - } - } else { - filteredResult = json["result"].filter { - $0.1["to"].stringValue.hasPrefix("0x") - } + let json = JSON(value) + let filteredResult: [(String, JSON)] + if let contractAddress = contractAddress { + //filter based on what contract you are after + filteredResult = json["result"].filter { + $0.1["contractAddress"].stringValue == contractAddress.eip55String.lowercased() } - let transactions: [Transaction] = filteredResult.map { result in - let transactionJson = result.1 - let localizedTokenObj = LocalizedOperationObject( - from: transactionJson["from"].stringValue, - to: transactionJson["to"].stringValue, - contract: AlphaWallet.Address(uncheckedAgainstNullAddress: transactionJson["contractAddress"].stringValue), - type: OperationType.erc20TokenTransfer.rawValue, - value: transactionJson["value"].stringValue, - symbol: transactionJson["tokenSymbol"].stringValue, - name: transactionJson["tokenName"].stringValue, - decimals: transactionJson["tokenDecimal"].intValue - ) - return Transaction( - id: transactionJson["hash"].stringValue, - server: server, - blockNumber: transactionJson["blockNumber"].intValue, - transactionIndex: transactionJson["transactionIndex"].intValue, - from: transactionJson["from"].stringValue, - to: transactionJson["to"].stringValue, - //Must not set the value of the ERC20 token transferred as the native crypto value transferred - value: "0", - gas: transactionJson["gas"].stringValue, - gasPrice: transactionJson["gasPrice"].stringValue, - gasUsed: transactionJson["gasUsed"].stringValue, - nonce: transactionJson["nonce"].stringValue, - date: Date(timeIntervalSince1970: transactionJson["timeStamp"].doubleValue), - localizedOperations: [localizedTokenObj], - //The API only returns successful transactions - state: .completed, - isErc20Interaction: true - ) - } - var results: [Transaction] = .init() - for each in transactions { - if let found: Transaction = results.first(where: { $0.blockNumber == each.blockNumber }) { - found.localizedOperations.append(objectsIn: each.localizedOperations) - } else { - results.append(each) - } - } - DispatchQueue.main.async { - completion(results) + } else { + filteredResult = json["result"].filter { + $0.1["to"].stringValue.hasPrefix("0x") } } + + let transactions: [TransactionInstance] = filteredResult.map { result in + let transactionJson = result.1 + let localizedTokenObj = LocalizedOperationObjectInstance( + from: transactionJson["from"].stringValue, + to: transactionJson["to"].stringValue, + contract: AlphaWallet.Address(uncheckedAgainstNullAddress: transactionJson["contractAddress"].stringValue), + type: OperationType.erc20TokenTransfer.rawValue, + value: transactionJson["value"].stringValue, + symbol: transactionJson["tokenSymbol"].stringValue, + name: transactionJson["tokenName"].stringValue, + decimals: transactionJson["tokenDecimal"].intValue + ) + + return TransactionInstance( + id: transactionJson["hash"].stringValue, + server: server, + blockNumber: transactionJson["blockNumber"].intValue, + transactionIndex: transactionJson["transactionIndex"].intValue, + from: transactionJson["from"].stringValue, + to: transactionJson["to"].stringValue, + value: transactionJson["value"].stringValue, + gas: transactionJson["gas"].stringValue, + gasPrice: transactionJson["gasPrice"].stringValue, + gasUsed: transactionJson["gasUsed"].stringValue, + nonce: transactionJson["nonce"].stringValue, + date: Date(timeIntervalSince1970: transactionJson["timeStamp"].doubleValue), + localizedOperations: [localizedTokenObj], + //The API only returns successful transactions + state: .completed, + isErc20Interaction: true + ) + } + + completion(transactions) case .failure: completion([]) } - } + }) } func getContractList(address: AlphaWallet.Address, server: RPCServer, startBlock: Int? = nil, erc20: Bool, completion: @escaping ([AlphaWallet.Address], Int?) -> Void) { diff --git a/AlphaWallet/Tokens/Coordinators/GetIsERC721ContractCoordinator.swift b/AlphaWallet/Tokens/Coordinators/GetIsERC721ContractCoordinator.swift index 6dcd0e935..39f295eb7 100644 --- a/AlphaWallet/Tokens/Coordinators/GetIsERC721ContractCoordinator.swift +++ b/AlphaWallet/Tokens/Coordinators/GetIsERC721ContractCoordinator.swift @@ -24,77 +24,91 @@ class GetIsERC721ContractCoordinator { //Using "kat" instead of "cryptokitties" to avoid being mistakenly detected by app review as supporting CryptoKitties static let onlyKat = "0x9a20483d" } - - init(forServer server: RPCServer) { + private let queue: DispatchQueue + init(forServer server: RPCServer, queue: DispatchQueue = .global()) { self.server = server + self.queue = queue } func getIsERC721Contract( for contract: AlphaWallet.Address, completion: @escaping (ResultResult.t) -> Void ) { - if contract.sameContract(as: DoesNotSupportERC165Querying.bitizen) { - completion(.success(true)) - return - } - if contract.sameContract(as: DoesNotSupportERC165Querying.cryptoSaga) { - completion(.success(true)) - return - } + let server = self.server + queue.async { + if contract.sameContract(as: DoesNotSupportERC165Querying.bitizen) { + completion(.success(true)) + return + } + if contract.sameContract(as: DoesNotSupportERC165Querying.cryptoSaga) { + completion(.success(true)) + return + } - //TODO use callSmartContract() instead + //TODO use callSmartContract() instead - guard let webProvider = Web3HttpProvider(server.rpcURL, network: server.web3Network) else { - completion(.failure(AnyError(Web3Error(description: "Error creating web provider for: \(server.rpcURL) + \(server.web3Network)")))) - return - } + guard let webProvider = Web3HttpProvider(server.rpcURL, network: server.web3Network) else { + completion(.failure(AnyError(Web3Error(description: "Error creating web provider for: \(server.rpcURL) + \(server.web3Network)")))) + return + } - let configuration = webProvider.session.configuration - configuration.timeoutIntervalForRequest = TokensDataStore.fetchContractDataTimeout - configuration.timeoutIntervalForResource = TokensDataStore.fetchContractDataTimeout - let session = URLSession(configuration: configuration) - webProvider.session = session + let configuration = webProvider.session.configuration + configuration.timeoutIntervalForRequest = TokensDataStore.fetchContractDataTimeout + configuration.timeoutIntervalForResource = TokensDataStore.fetchContractDataTimeout + let session = URLSession(configuration: configuration) + webProvider.session = session - let contractAddress = EthereumAddress(address: contract) - let web3 = web3swift.web3(provider: webProvider) - let function = GetInterfaceSupported165Encode() - guard let contractInstance = web3swift.web3.web3contract(web3: web3, abiString: function.abi, at: contractAddress, options: web3.options) else { - completion(.failure(AnyError(Web3Error(description: "Error creating web3swift contract instance to call \(function.name)()")))) - return - } + let contractAddress = EthereumAddress(address: contract) + let web3 = web3swift.web3(provider: webProvider) + let function = GetInterfaceSupported165Encode() + guard let contractInstance = web3swift.web3.web3contract(web3: web3, abiString: function.abi, at: contractAddress, options: web3.options) else { + completion(.failure(AnyError(Web3Error(description: "Error creating web3swift contract instance to call \(function.name)()")))) + return + } - guard let cryptoKittyPromise = contractInstance.method(function.name, parameters: [ERC165Hash.onlyKat] as [AnyObject], options: nil)?.callPromise(options: nil) else { - completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.onlyKat)")))) - return - } + guard let cryptoKittyPromise = contractInstance.method(function.name, parameters: [ERC165Hash.onlyKat] as [AnyObject], options: nil)?.callPromise(options: nil) else { + completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.onlyKat)")))) + return + } - guard let nonCryptoKittyERC721Promise = contractInstance.method(function.name, parameters: [ERC165Hash.official] as [AnyObject], options: nil)?.callPromise(options: nil) else { - completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.official)")))) - return - } + guard let nonCryptoKittyERC721Promise = contractInstance.method(function.name, parameters: [ERC165Hash.official] as [AnyObject], options: nil)?.callPromise(options: nil) else { + completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.official)")))) + return + } - guard let nonCryptoKittyERC721WithOldInterfaceHashPromise = contractInstance.method(function.name, parameters: [ERC165Hash.old] as [AnyObject], options: nil)?.callPromise(options: nil) else { - completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.old)")))) - return - } + guard let nonCryptoKittyERC721WithOldInterfaceHashPromise = contractInstance.method(function.name, parameters: [ERC165Hash.old] as [AnyObject], options: nil)?.callPromise(options: nil) else { + completion(.failure(AnyError(Web3Error(description: "Error calling \(function.name)() on \(contract.eip55String) with params: \(ERC165Hash.old)")))) + return + } - //Slower than theoretically possible because we wait for every promise to be resolved. In theory we can stop when any promise is fulfilled with true. But code is much less elegant - firstly { - when(resolved: cryptoKittyPromise, nonCryptoKittyERC721Promise, nonCryptoKittyERC721WithOldInterfaceHashPromise) - }.done { _ in - let isCryptoKitty = cryptoKittyPromise.value?["0"] as? Bool - let isNonCryptoKittyERC721 = nonCryptoKittyERC721Promise.value?["0"] as? Bool - let isNonCryptoKittyERC721WithOldInterfaceHash = nonCryptoKittyERC721WithOldInterfaceHashPromise.value?["0"] as? Bool - if let isCryptoKitty = isCryptoKitty, isCryptoKitty { - completion(.success(true)) - } else if let isNonCryptoKittyERC721 = isNonCryptoKittyERC721, isNonCryptoKittyERC721 { - completion(.success(true)) - } else if let isNonCryptoKittyERC721WithOldInterfaceHash = isNonCryptoKittyERC721WithOldInterfaceHash, isNonCryptoKittyERC721WithOldInterfaceHash { - completion(.success(true)) - } else if isCryptoKitty != nil, isNonCryptoKittyERC721 != nil, isNonCryptoKittyERC721WithOldInterfaceHash != nil { - completion(.success(false)) - } else { - completion(.failure(AnyError(Web3Error(description: "Error extracting result from \(contract.eip55String).\(function.name)()")))) + //Slower than theoretically possible because we wait for every promise to be resolved. In theory we can stop when any promise is fulfilled with true. But code is much less elegant + firstly { + when(resolved: cryptoKittyPromise, nonCryptoKittyERC721Promise, nonCryptoKittyERC721WithOldInterfaceHashPromise) + }.done(on: self.queue) { _ in + let isCryptoKitty = cryptoKittyPromise.value?["0"] as? Bool + let isNonCryptoKittyERC721 = nonCryptoKittyERC721Promise.value?["0"] as? Bool + let isNonCryptoKittyERC721WithOldInterfaceHash = nonCryptoKittyERC721WithOldInterfaceHashPromise.value?["0"] as? Bool + if let isCryptoKitty = isCryptoKitty, isCryptoKitty { + DispatchQueue.main.async { + completion(.success(true)) + } + } else if let isNonCryptoKittyERC721 = isNonCryptoKittyERC721, isNonCryptoKittyERC721 { + DispatchQueue.main.async { + completion(.success(true)) + } + } else if let isNonCryptoKittyERC721WithOldInterfaceHash = isNonCryptoKittyERC721WithOldInterfaceHash, isNonCryptoKittyERC721WithOldInterfaceHash { + DispatchQueue.main.async { + completion(.success(true)) + } + } else if isCryptoKitty != nil, isNonCryptoKittyERC721 != nil, isNonCryptoKittyERC721WithOldInterfaceHash != nil { + DispatchQueue.main.async { + completion(.success(false)) + } + } else { + DispatchQueue.main.async { + completion(.failure(AnyError(Web3Error(description: "Error extracting result from \(contract.eip55String).\(function.name)()")))) + } + } } } } diff --git a/AlphaWallet/Tokens/Coordinators/SingleChainTokenCoordinator.swift b/AlphaWallet/Tokens/Coordinators/SingleChainTokenCoordinator.swift index 9771a3e29..d2fbf2590 100644 --- a/AlphaWallet/Tokens/Coordinators/SingleChainTokenCoordinator.swift +++ b/AlphaWallet/Tokens/Coordinators/SingleChainTokenCoordinator.swift @@ -30,7 +30,7 @@ protocol SingleChainTokenCoordinatorDelegate: class, CanOpenURL { func didTapSwap(forTransactionType transactionType: TransactionType, service: SwapTokenURLProviderType, in coordinator: SingleChainTokenCoordinator) func shouldOpen(url: URL, onServer server: RPCServer, forTransactionType transactionType: TransactionType, in coordinator: SingleChainTokenCoordinator) func didPress(for type: PaymentFlow, inCoordinator coordinator: SingleChainTokenCoordinator) - func didTap(transaction: Transaction, inViewController viewController: UIViewController, in coordinator: SingleChainTokenCoordinator) + func didTap(transaction: TransactionInstance, inViewController viewController: UIViewController, in coordinator: SingleChainTokenCoordinator) func didPostTokenScriptTransaction(_ transaction: SentTransaction, in coordinator: SingleChainTokenCoordinator) } @@ -109,7 +109,7 @@ class SingleChainTokenCoordinator: Coordinator { } else { startBlock = Config.getLastFetchedAutoDetectedTransactedTokenNonErc20BlockNumber(session.server, wallet: wallet).flatMap { $0 + 1 } } - GetContractInteractions().getContractList(address: wallet, server: session.server, startBlock: startBlock, erc20: erc20) { [weak self] contracts, maxBlockNumber in + GetContractInteractions(queue: .main).getContractList(address: wallet, server: session.server, startBlock: startBlock, erc20: erc20) { [weak self] contracts, maxBlockNumber in guard let strongSelf = self else { return } defer { seal.fulfill(()) @@ -574,7 +574,7 @@ extension SingleChainTokenCoordinator: TokenViewControllerDelegate { delegate?.didPress(for: .request, inCoordinator: self) } - func didTap(transaction: Transaction, inViewController viewController: TokenViewController) { + func didTap(transaction: TransactionInstance, inViewController viewController: TokenViewController) { delegate?.didTap(transaction: transaction, inViewController: viewController, in: self) } diff --git a/AlphaWallet/Tokens/Coordinators/TokensCoordinator.swift b/AlphaWallet/Tokens/Coordinators/TokensCoordinator.swift index d0b766a3b..5f4498818 100644 --- a/AlphaWallet/Tokens/Coordinators/TokensCoordinator.swift +++ b/AlphaWallet/Tokens/Coordinators/TokensCoordinator.swift @@ -8,7 +8,7 @@ protocol TokensCoordinatorDelegate: class, CanOpenURL { func didTapSwap(forTransactionType transactionType: TransactionType, service: SwapTokenURLProviderType, in coordinator: TokensCoordinator) func shouldOpen(url: URL, onServer server: RPCServer, forTransactionType transactionType: TransactionType, in coordinator: TokensCoordinator) func didPress(for type: PaymentFlow, server: RPCServer, in coordinator: TokensCoordinator) - func didTap(transaction: Transaction, inViewController viewController: UIViewController, in coordinator: TokensCoordinator) + func didTap(transaction: TransactionInstance, inViewController viewController: UIViewController, in coordinator: TokensCoordinator) func openConsole(inCoordinator coordinator: TokensCoordinator) func didPostTokenScriptTransaction(_ transaction: SentTransaction, in coordinator: TokensCoordinator) } @@ -401,7 +401,7 @@ extension TokensCoordinator: SingleChainTokenCoordinatorDelegate { delegate?.didPress(for: type, server: coordinator.session.server, in: self) } - func didTap(transaction: Transaction, inViewController viewController: UIViewController, in coordinator: SingleChainTokenCoordinator) { + func didTap(transaction: TransactionInstance, inViewController viewController: UIViewController, in coordinator: SingleChainTokenCoordinator) { delegate?.didTap(transaction: transaction, inViewController: viewController, in: self) } diff --git a/AlphaWallet/Tokens/Helpers/CallSmartContractFunction.swift b/AlphaWallet/Tokens/Helpers/CallSmartContractFunction.swift index 27b66134b..9fe7aedaa 100644 --- a/AlphaWallet/Tokens/Helpers/CallSmartContractFunction.swift +++ b/AlphaWallet/Tokens/Helpers/CallSmartContractFunction.swift @@ -8,10 +8,50 @@ import web3swift //TODO wrap callSmartContract() and cache into a type // swiftlint:disable private_over_fileprivate -fileprivate var smartContractCallsCache = [String: (promise: Promise<[String: Any]>, timestamp: Date)]() -fileprivate var web3s = [RPCServer: [TimeInterval: web3]]() +fileprivate var smartContractCallsCache = ThreadSafeContractCallsCache() +fileprivate var web3s = ThreadSafeWeb3sCache() // swiftlint:enable private_over_fileprivate +private class ThreadSafeWeb3sCache { + fileprivate var cache = [RPCServer: [TimeInterval: web3]]() + private let queue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(server: RPCServer) -> [TimeInterval: web3]? { + get { + var element: [TimeInterval: web3]? + queue.sync { + element = cache[server] + } + return element + } + set { + queue.async(flags: .barrier) { + self.cache[server] = newValue + } + } + } +} + +private class ThreadSafeContractCallsCache { + fileprivate var cache = [String: (promise: Promise<[String: Any]>, timestamp: Date)]() + private let queue = DispatchQueue(label: "SynchronizedArrayAccess", attributes: .concurrent) + + subscript(key: String) -> (promise: Promise<[String: Any]>, timestamp: Date)? { + get { + var element: (promise: Promise<[String: Any]>, timestamp: Date)? + queue.sync { + element = cache[key] + } + return element + } + set { + queue.async(flags: .barrier) { + self.cache[key] = newValue + } + } + } +} + func getCachedWeb3(forServer server: RPCServer, timeout: TimeInterval) throws -> web3 { if let result = web3s[server]?[timeout] { return result @@ -37,6 +77,7 @@ func getCachedWeb3(forServer server: RPCServer, timeout: TimeInterval) throws -> } } +private let callSmartContractQueue = DispatchQueue(label: "com.callSmartContractQueue.updateQueue") func callSmartContract(withServer server: RPCServer, contract: AlphaWallet.Address, functionName: String, abiString: String, parameters: [AnyObject] = [AnyObject](), timeout: TimeInterval? = nil) -> Promise<[String: Any]> { let timeout: TimeInterval = 60 //We must include the ABI string in the key because the order of elements in a dictionary when serialized in the string is not ordered. Parameters (which is ordered) should ensure it's the same function @@ -48,10 +89,10 @@ func callSmartContract(withServer server: RPCServer, contract: AlphaWallet.Addre if diff < ttlForCache { //HACK: We can't return the cachedPromise directly and immediately because if we use the value as a TokenScript attribute in a TokenScript view, timing issues will cause the webview to not load properly or for the injection with updates to fail return Promise { seal in - DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { - cachedPromise.done { + callSmartContractQueue.asyncAfter(deadline: .now() + 0.7) { + cachedPromise.done(on: .main) { seal.fulfill($0) - }.catch { + }.catch(on: .main) { seal.reject($0) } } @@ -60,28 +101,33 @@ func callSmartContract(withServer server: RPCServer, contract: AlphaWallet.Addre } let result: Promise<[String: Any]> = Promise { seal in - guard let web3 = try? getCachedWeb3(forServer: server, timeout: timeout) else { - throw Web3Error(description: "Error creating web3 for: \(server.rpcURL) + \(server.web3Network)") - } + callSmartContractQueue.async { + guard let web3 = try? getCachedWeb3(forServer: server, timeout: timeout) else { + seal.reject( Web3Error(description: "Error creating web3 for: \(server.rpcURL) + \(server.web3Network)")) + return + } - let contractAddress = EthereumAddress(address: contract) + let contractAddress = EthereumAddress(address: contract) - guard let contractInstance = web3swift.web3.web3contract(web3: web3, abiString: abiString, at: contractAddress, options: web3.options) else { - throw Web3Error(description: "Error creating web3swift contract instance to call \(functionName)()") - } - guard let promiseCreator = contractInstance.method(functionName, parameters: parameters, options: nil) else { - throw Web3Error(description: "Error calling \(contract.eip55String).\(functionName)() with parameters: \(parameters)") - } + guard let contractInstance = web3swift.web3.web3contract(web3: web3, abiString: abiString, at: contractAddress, options: web3.options) else { + seal.reject( Web3Error(description: "Error creating web3swift contract instance to call \(functionName)()")) + return + } + guard let promiseCreator = contractInstance.method(functionName, parameters: parameters, options: nil) else { + seal.reject( Web3Error(description: "Error calling \(contract.eip55String).\(functionName)() with parameters: \(parameters)")) + return + } - //callPromise() creates a promise. It doesn't "call" a promise. Bad name - promiseCreator.callPromise(options: nil).done { - seal.fulfill($0) - }.catch { - seal.reject($0) + //callPromise() creates a promise. It doesn't "call" a promise. Bad name + promiseCreator.callPromise(options: nil).done(on: .main) { d in + seal.fulfill(d) + }.catch(on: .main) { e in + seal.reject(e) + } } } - smartContractCallsCache[cacheKey] = (result, now) + smartContractCallsCache[cacheKey] = (result, now) return result } @@ -90,12 +136,13 @@ func getEventLogs( contract: AlphaWallet.Address, eventName: String, abiString: String, - filter: EventFilter + filter: EventFilter, + queue: DispatchQueue ) -> Promise<[EventParserResultProtocol]> { - firstly { () -> Promise<(EthereumAddress)> in + firstly { () -> Promise in let contractAddress = EthereumAddress(address: contract) return .value(contractAddress) - }.then { contractAddress -> Promise<[EventParserResultProtocol]> in + }.then(on: queue) { contractAddress -> Promise<[EventParserResultProtocol]> in guard let web3 = try? getCachedWeb3(forServer: server, timeout: 60) else { throw Web3Error(description: "Error creating web3 for: \(server.rpcURL) + \(server.web3Network)") } @@ -104,9 +151,6 @@ func getEventLogs( return Promise(error: Web3Error(description: "Error creating web3swift contract instance to call \(eventName)()")) } - return contractInstance.getIndexedEventsPromise( - eventName: eventName, - filter: filter - ) - } + return contractInstance.getIndexedEventsPromise(eventName: eventName, filter: filter) + } } diff --git a/AlphaWallet/Tokens/Types/EventActivity.swift b/AlphaWallet/Tokens/Types/EventActivity.swift index a2d63cf73..30ec3cde0 100644 --- a/AlphaWallet/Tokens/Types/EventActivity.swift +++ b/AlphaWallet/Tokens/Types/EventActivity.swift @@ -63,6 +63,23 @@ class EventActivity: Object { self._data = EventActivity.convertJsonToDictionary(json) } + convenience init(value: EventActivityInstance) { + self.init() + self.primaryKey = value.primaryKey + self.contract = value.contract.eip55String + self.tokenContract = value.tokenContract.eip55String + self.chainId = value.server.chainID + self.date = value.date + self.eventName = value.eventName + self.blockNumber = value.blockNumber + self.transactionId = value.transactionId + self.transactionIndex = value.transactionIndex + self.logIndex = value.logIndex + self.filter = value.filter + self.json = value.json + self._data = value._data + } + override static func primaryKey() -> String? { return "primaryKey" } @@ -86,3 +103,71 @@ class EventActivity: Object { } } +struct EventActivityInstance { + static func generatePrimaryKey(fromContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, blockNumber: Int, transactionId: String, logIndex: Int, filter: String) -> String { + "\(contract.eip55String)-\(tokenContract.eip55String)-\(server.chainID)-\(eventName)-\(blockNumber)-\(transactionId)-\(logIndex)-\(filter)" + } + + var primaryKey: String = "" + var contract: AlphaWallet.Address + var tokenContract: AlphaWallet.Address + var server: RPCServer + var date = Date() + var eventName: String = "" + var blockNumber: Int = 0 + var transactionId: String = "" + var transactionIndex: Int = 0 + var logIndex: Int = 0 + var filter: String = "" + var json: String = "{}" + + //Needed because Realm objects' properties (`json`) don't fire didSet after the object has been written to the database + var _data: [String: AssetInternalValue]? + + init(event: EventActivity) { + self.primaryKey = event.primaryKey + + self.contract = AlphaWallet.Address(uncheckedAgainstNullAddress: event.contract)! + self.tokenContract = AlphaWallet.Address(uncheckedAgainstNullAddress: event.tokenContract)! + self.server = RPCServer(chainID: event.chainId) + self.date = event.date + self.eventName = event.eventName + self.blockNumber = event.blockNumber + self.transactionId = event.transactionId + self.transactionIndex = event.transactionIndex + self.logIndex = event.logIndex + self.filter = event.filter + self.json = event.json + self._data = event._data + } + + init(contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, date: Date, eventName: String, blockNumber: Int, transactionId: String, transactionIndex: Int, logIndex: Int, filter: String, json: String) { + self.primaryKey = EventActivity.generatePrimaryKey(fromContract: contract, tokenContract: tokenContract, server: server, eventName: eventName, blockNumber: blockNumber, transactionId: transactionId, logIndex: logIndex, filter: filter) + self.contract = contract + self.tokenContract = tokenContract + self.server = server + self.date = date + self.eventName = eventName + self.blockNumber = blockNumber + self.transactionId = transactionId + self.transactionIndex = transactionIndex + self.logIndex = logIndex + self.filter = filter + self.json = json + self._data = EventActivityInstance.convertJsonToDictionary(json) + } + + private static func convertJsonToDictionary(_ json: String) -> [String: AssetInternalValue] { + let dict = json.data(using: .utf8).flatMap({ (try? JSONSerialization.jsonObject(with: $0, options: [])) as? [String: Any] }) ?? .init() + return Dictionary(uniqueKeysWithValues: dict.compactMap { key, value -> (String, AssetInternalValue)? in + switch value { + case let string as String: + return (key, .string(string)) + case let number as NSNumber: + return (key, .string(String(describing: number))) + default: + return nil + } + }) + } +} diff --git a/AlphaWallet/Tokens/Types/EventInstance.swift b/AlphaWallet/Tokens/Types/EventInstance.swift index d158f51e2..a4a68798a 100644 --- a/AlphaWallet/Tokens/Types/EventInstance.swift +++ b/AlphaWallet/Tokens/Types/EventInstance.swift @@ -34,18 +34,19 @@ class EventInstance: Object { } } - convenience init(contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, blockNumber: Int, logIndex: Int, filter: String, json: String) { + convenience init(event: EventInstanceValue) { self.init() - self.primaryKey = EventInstance.generatePrimaryKey(fromContract: contract, tokenContract: tokenContract, server: server, eventName: eventName, blockNumber: blockNumber, logIndex: logIndex, filter: filter) - self.contract = contract.eip55String - self.tokenContract = tokenContract.eip55String - self.chainId = server.chainID - self.eventName = eventName - self.blockNumber = blockNumber - self.logIndex = logIndex - self.filter = filter - self.json = json - self._data = EventInstance.convertJsonToDictionary(json) + + self.primaryKey = event.primaryKey + self.contract = event.contract + self.tokenContract = event.tokenContract + self.chainId = event.chainId + self.eventName = event.eventName + self.blockNumber = event.blockNumber + self.logIndex = event.logIndex + self.filter = event.filter + self.json = event.json + self._data = event._data } override static func primaryKey() -> String? { @@ -70,4 +71,3 @@ class EventInstance: Object { }) } } - diff --git a/AlphaWallet/Tokens/Types/EventInstanceValue.swift b/AlphaWallet/Tokens/Types/EventInstanceValue.swift new file mode 100644 index 000000000..d6145cdf2 --- /dev/null +++ b/AlphaWallet/Tokens/Types/EventInstanceValue.swift @@ -0,0 +1,62 @@ +// +// EventInstanceValue.swift +// AlphaWallet +// +// Created by Vladyslav Shepitko on 13.01.2021. +// + +import UIKit + +struct EventInstanceValue { + var primaryKey: String + var contract: String + var tokenContract: String + var chainId: Int + var eventName: String + var blockNumber: Int + var logIndex: Int + var filter: String + var json: String + var _data: [String: AssetInternalValue]? + + init(contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, blockNumber: Int, logIndex: Int, filter: String, json: String) { + self.primaryKey = EventInstance.generatePrimaryKey(fromContract: contract, tokenContract: tokenContract, server: server, eventName: eventName, blockNumber: blockNumber, logIndex: logIndex, filter: filter) + self.contract = contract.eip55String + self.tokenContract = tokenContract.eip55String + self.chainId = server.chainID + self.eventName = eventName + self.blockNumber = blockNumber + self.logIndex = logIndex + self.filter = filter + self.json = json + self._data = EventInstanceValue.convertJsonToDictionary(json) + } + + init(event: EventInstance) { + self.primaryKey = event.primaryKey + self.contract = event.contract + self.tokenContract = event.tokenContract + self.chainId = event.chainId + self.eventName = event.eventName + self.blockNumber = event.blockNumber + self.logIndex = event.logIndex + self.filter = event.filter + self.json = event.json + self._data = event._data + } + + private static func convertJsonToDictionary(_ json: String) -> [String: AssetInternalValue] { + let dict = json.data(using: .utf8).flatMap({ (try? JSONSerialization.jsonObject(with: $0, options: [])) as? [String: Any] }) ?? .init() + return Dictionary(uniqueKeysWithValues: dict.compactMap { key, value -> (String, AssetInternalValue)? in + switch value { + case let string as String: + return (key, .string(string)) + case let number as NSNumber: + return (key, .string(String(describing: number))) + default: + return nil + } + }) + } +} + diff --git a/AlphaWallet/Tokens/Types/TokenCollection.swift b/AlphaWallet/Tokens/Types/TokenCollection.swift index c6672e3a5..7352fa97e 100644 --- a/AlphaWallet/Tokens/Types/TokenCollection.swift +++ b/AlphaWallet/Tokens/Types/TokenCollection.swift @@ -33,16 +33,24 @@ class TokenCollection { extension TokenCollection: TokensDataStoreDelegate { func didUpdate(result: Result, refreshImmediately: Bool = false) { if refreshImmediately { - notifySubscribersOfUpdatedTokens() + DispatchQueue.main.async { + self.notifySubscribersOfUpdatedTokens() + } + return } //The first time, we notify the subscribers and hence load the data in the UI immediately, otherwise the list of tokens in the Wallet tab will be empty for a few seconds after launch if rateLimitedUpdater == nil { rateLimitedUpdater = RateLimiter(limit: 2) { [weak self] in - self?.notifySubscribersOfUpdatedTokens() + DispatchQueue.main.async { + self?.notifySubscribersOfUpdatedTokens() + } + } + + DispatchQueue.main.async { + self.notifySubscribersOfUpdatedTokens() } - notifySubscribersOfUpdatedTokens() } else { rateLimitedUpdater?.run() } diff --git a/AlphaWallet/Tokens/Types/TokenObject.swift b/AlphaWallet/Tokens/Types/TokenObject.swift index 03f16d864..dda1edd20 100644 --- a/AlphaWallet/Tokens/Types/TokenObject.swift +++ b/AlphaWallet/Tokens/Types/TokenObject.swift @@ -4,6 +4,28 @@ import Foundation import RealmSwift import BigInt +extension Activity { + + struct AssignedToken { + var primaryKey: String + var contractAddress: AlphaWallet.Address + var symbol: String + var decimals: Int + var server: RPCServer + var icon: Subscribable + var type: TokenType + init(tokenObject: TokenObject) { + primaryKey = tokenObject.primaryKey + server = tokenObject.server + contractAddress = tokenObject.contractAddress + symbol = tokenObject.symbol + decimals = tokenObject.decimals + icon = tokenObject.icon + type = tokenObject.type + } + } +} + class TokenObject: Object { static func generatePrimaryKey(fromContract contract: AlphaWallet.Address, server: RPCServer) -> String { return "\(contract.eip55String)-\(server.chainID)" @@ -57,14 +79,9 @@ class TokenObject: Object { self.isDisabled = isDisabled self.type = type } - + var contractAddress: AlphaWallet.Address { - get { - AlphaWallet.Address(uncheckedAgainstNullAddress: contract)! - } - set { - contract = newValue.eip55String - } + return AlphaWallet.Address(uncheckedAgainstNullAddress: contract)! } var valueBigInt: BigInt { @@ -81,6 +98,7 @@ class TokenObject: Object { override func isEqual(_ object: Any?) -> Bool { guard let object = object as? TokenObject else { return false } + //NOTE: to improve perfomance seems like we can use check for primary key instead of checking contracts return object.contractAddress.sameContract(as: contractAddress) } diff --git a/AlphaWallet/Tokens/Types/TokensDataStore.swift b/AlphaWallet/Tokens/Types/TokensDataStore.swift index 431bbf51d..0cd30de67 100644 --- a/AlphaWallet/Tokens/Types/TokensDataStore.swift +++ b/AlphaWallet/Tokens/Types/TokensDataStore.swift @@ -81,6 +81,7 @@ class TokensDataStore { private var isFetchingPrices = false private let config: Config private let openSea: OpenSea + private let queue = DispatchQueue.global() let server: RPCServer weak var delegate: TokensDataStoreDelegate? @@ -98,23 +99,23 @@ class TokensDataStore { //TODO might be good to change `enabledObject` to just return the streaming list from Realm instead of a Swift native Array and other properties/callers can convert to Array if necessary var enabledObject: [TokenObject] { - return Array(realm.objects(TokenObject.self) + return Array(realm.threadSafe.objects(TokenObject.self) .filter("chainId = \(self.chainId)") .filter("isDisabled = false")) } var deletedContracts: [DeletedContract] { - return Array(realm.objects(DeletedContract.self) + return Array(realm.threadSafe.objects(DeletedContract.self) .filter("chainId = \(self.chainId)")) } var delegateContracts: [DelegateContract] { - return Array(realm.objects(DelegateContract.self) + return Array(realm.threadSafe.objects(DelegateContract.self) .filter("chainId = \(self.chainId)")) } var hiddenContracts: [HiddenContract] { - return Array(realm.objects(HiddenContract.self) + return Array(realm.threadSafe.objects(HiddenContract.self) .filter("chainId = \(self.chainId)")) } @@ -437,6 +438,12 @@ class TokensDataStore { } } + func tokenThreadSafe(forContract contract: AlphaWallet.Address) -> TokenObject? { + realm.threadSafe.objects(TokenObject.self) + .filter("contract = '\(contract.eip55String)'") + .filter("chainId = \(chainId)").first + } + func token(forContract contract: AlphaWallet.Address) -> TokenObject? { realm.objects(TokenObject.self) .filter("contract = '\(contract.eip55String)'") @@ -765,7 +772,7 @@ class TokensDataStore { case .nonFungibleBalance(let balance): //Performance: if we use realm.write {} directly, the UI will block for a few seconds because we are reading from Realm, appending to an array and writing back to Realm many times (once for each token) in the main thread. Instead, we do this for each token in a background thread let primaryKey = token.primaryKey - DispatchQueue.global().async { + queue.async { let realmInBackground = try! Realm(configuration: self.realm.configuration) let token = realmInBackground.object(ofType: TokenObject.self, forPrimaryKey: primaryKey)! var newBalance = [TokenBalance]() @@ -859,3 +866,9 @@ class TokensDataStore { } } // swiftlint:enable type_body_length + +extension Realm { + var threadSafe: Realm { + try! Realm(configuration: self.configuration) + } +} diff --git a/AlphaWallet/Tokens/Types/TransactionCollection.swift b/AlphaWallet/Tokens/Types/TransactionCollection.swift index 24ba26394..157c72273 100644 --- a/AlphaWallet/Tokens/Types/TransactionCollection.swift +++ b/AlphaWallet/Tokens/Types/TransactionCollection.swift @@ -16,18 +16,17 @@ class TransactionCollection { guard let server = items.first?.server else { return [] } guard let storage = transactionsStorages.first(where: { $0.server == server }) else { return [] } return storage.add(items) - } + } - var objects: [Transaction] { - var transactions = [Transaction]() + var objects: [TransactionInstance] { //Concatenate arrays of hundreds/thousands of elements. Room for speed improvement, but it seems good enough so far. It'll be much more efficient if we do a single read from Realm directly - for each in transactionsStorages { - transactions.append(contentsOf: Array(each.objects)) + + return transactionsStorages.flatMap { + return $0.objects.map { TransactionInstance(transaction: $0) } } - return transactions } - func transaction(withTransactionId transactionId: String, server: RPCServer) -> Transaction? { + func transaction(withTransactionId transactionId: String, server: RPCServer) -> TransactionInstance? { guard let storage = transactionsStorages.first(where: { $0.server == server }) else { return nil } return storage.transaction(withTransactionId: transactionId) } diff --git a/AlphaWallet/Tokens/ViewControllers/TokenViewController.swift b/AlphaWallet/Tokens/ViewControllers/TokenViewController.swift index bc95d60da..458118da8 100644 --- a/AlphaWallet/Tokens/ViewControllers/TokenViewController.swift +++ b/AlphaWallet/Tokens/ViewControllers/TokenViewController.swift @@ -10,7 +10,7 @@ protocol TokenViewControllerDelegate: class, CanOpenURL { func shouldOpen(url: URL, onServer server: RPCServer, forTransactionType transactionType: TransactionType, inViewController viewController: TokenViewController) func didTapSend(forTransactionType transactionType: TransactionType, inViewController viewController: TokenViewController) func didTapReceive(forTransactionType transactionType: TransactionType, inViewController viewController: TokenViewController) - func didTap(transaction: Transaction, inViewController viewController: TokenViewController) + func didTap(transaction: TransactionInstance, inViewController viewController: TokenViewController) func didTap(action: TokenInstanceAction, transactionType: TransactionType, viewController: TokenViewController) } diff --git a/AlphaWallet/Tokens/ViewModels/TokenViewControllerTransactionCellViewModel.swift b/AlphaWallet/Tokens/ViewModels/TokenViewControllerTransactionCellViewModel.swift index b933d9171..e2cbd5552 100644 --- a/AlphaWallet/Tokens/ViewModels/TokenViewControllerTransactionCellViewModel.swift +++ b/AlphaWallet/Tokens/ViewModels/TokenViewControllerTransactionCellViewModel.swift @@ -4,11 +4,11 @@ import Foundation import UIKit struct TokenViewControllerTransactionCellViewModel { - private let transaction: Transaction + private let transaction: TransactionInstance private let transactionViewModel: TransactionViewModel init( - transaction: Transaction, + transaction: TransactionInstance, config: Config, chainState: ChainState, currentWallet: Wallet diff --git a/AlphaWallet/Tokens/ViewModels/TokenViewControllerViewModel.swift b/AlphaWallet/Tokens/ViewModels/TokenViewControllerViewModel.swift index 79f6d543e..5bee00cd9 100644 --- a/AlphaWallet/Tokens/ViewModels/TokenViewControllerViewModel.swift +++ b/AlphaWallet/Tokens/ViewModels/TokenViewControllerViewModel.swift @@ -25,7 +25,7 @@ struct TokenViewControllerViewModel { } } - let recentTransactions: [Transaction] + let recentTransactions: [TransactionInstance] var actions: [TokenInstanceAction] { guard let token = token else { return [] } @@ -124,22 +124,31 @@ struct TokenViewControllerViewModel { switch transactionType { case .nativeCryptocurrency: self.recentTransactions = Array(transactionsStore.objects.lazy - .filter({ $0.state == .completed || $0.state == .pending }) - .filter({ $0.operation == nil }) - .filter({ $0.value != "" && $0.value != "0" }) - .prefix(3)) + .filter({ TokenViewControllerViewModel.filterTransactionsForNativeCryptocurrency(transaction: $0) }) + .prefix(3) + .map { TransactionInstance(transaction: $0) }) + case .ERC20Token(let token, _, _): self.recentTransactions = Array(transactionsStore.objects.lazy - .filter({ $0.state == .completed || $0.state == .pending }) - .filter({ - $0.localizedOperations.contains(where: { op in op.operationType == .erc20TokenTransfer && (op.contract.flatMap({ token.contractAddress.sameContract(as: $0) }) ?? false) }) - }) + .filter({ TokenViewControllerViewModel.filterTransactionsForERC20Token(transaction: $0, tokenObject: token) }) .prefix(3)) + .map { TransactionInstance(transaction: $0) } + case .ERC875Token, .ERC875TokenOrder, .ERC721Token, .ERC721ForTicketToken, .dapp, .tokenScript, .claimPaidErc875MagicLink: self.recentTransactions = [] } } + private static func filterTransactionsForNativeCryptocurrency(transaction: Transaction) -> Bool { + (transaction.state == .completed || transaction.state == .pending) && (transaction.operation == nil) && (transaction.value != "" && transaction.value != "0") + } + + private static func filterTransactionsForERC20Token(transaction: Transaction, tokenObject token: TokenObject) -> Bool { + (transaction.state == .completed || transaction.state == .pending) && transaction.localizedOperations.contains(where: { op in + op.operationType == .erc20TokenTransfer && (op.contract.flatMap({ token.contractAddress.sameContract(as: $0) }) ?? false) + }) + } + var destinationAddress: AlphaWallet.Address { return transactionType.contract } diff --git a/AlphaWallet/Tokens/Views/OpenSea/OpenSeaNonFungibleTokenViewCell.swift b/AlphaWallet/Tokens/Views/OpenSea/OpenSeaNonFungibleTokenViewCell.swift index 4ea26d126..b34bd29eb 100644 --- a/AlphaWallet/Tokens/Views/OpenSea/OpenSeaNonFungibleTokenViewCell.swift +++ b/AlphaWallet/Tokens/Views/OpenSea/OpenSeaNonFungibleTokenViewCell.swift @@ -47,7 +47,10 @@ class OpenSeaNonFungibleTokenViewCell: UICollectionViewCell { override func layoutSubviews() { super.layoutSubviews() - setupParallaxEffect(forView: imageView, max: 20) + + DispatchQueue.main.async { + self.setupParallaxEffect(forView: self.imageView, max: 20) + } } func configure(viewModel: OpenSeaNonFungibleTokenViewCellViewModel) { diff --git a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionDataCoordinator.swift b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionDataCoordinator.swift index 2cfa63146..fd457ae40 100644 --- a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionDataCoordinator.swift +++ b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionDataCoordinator.swift @@ -11,6 +11,7 @@ protocol SingleChainTransactionDataCoordinator: Coordinator { var delegate: SingleChainTransactionDataCoordinatorDelegate? { get set } + var session: WalletSession { get } func start() func stopTimers() func runScheduledTimers() diff --git a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift index 50ae5051e..c07134370 100644 --- a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift +++ b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift @@ -11,11 +11,12 @@ import UserNotifications class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionDataCoordinator { private let storage: TransactionsStorage - private let session: WalletSession + let session: WalletSession private let keystore: Keystore private let tokensStorage: TokensDataStore private let promptBackupCoordinator: PromptBackupCoordinator private let fetchLatestTransactionsQueue: OperationQueue + private let queue = DispatchQueue(label: "com.SingleChainTransaction.updateQueue") private var timer: Timer? private var updateTransactionsTimer: Timer? private lazy var transactionsTracker: TransactionsTracker = { @@ -23,7 +24,8 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData }() private let alphaWalletProvider = AlphaWalletProviderFactory.makeProvider() - var isFetchingLatestTransactions = false + private var isAutoDetectingERC20Transactions: Bool = false + private var isFetchingLatestTransactions = false var coordinators: [Coordinator] = [] weak var delegate: SingleChainTransactionDataCoordinatorDelegate? @@ -62,52 +64,65 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData guard timer == nil, updateTransactionsTimer == nil else { return } + timer = Timer.scheduledTimer(timeInterval: 5, target: BlockOperation { [weak self] in - self?.fetchPending() + guard let strongSelf = self else { return } + + strongSelf.queue.async { + strongSelf.fetchPendingTransactions() + } }, selector: #selector(Operation.main), userInfo: nil, repeats: true) + updateTransactionsTimer = Timer.scheduledTimer(timeInterval: 15, target: BlockOperation { [weak self] in - self?.fetchLatestTransactions() - self?.autoDetectERC20Transactions() - }, selector: #selector(Operation.main), userInfo: nil, repeats: true) - } + guard let strongSelf = self else { return } - private func fetchPending() { - fetchPendingTransactions() + strongSelf.queue.async { + strongSelf.fetchLatestTransactions() + strongSelf.autoDetectERC20Transactions() + } + }, selector: #selector(Operation.main), userInfo: nil, repeats: true) } //TODO should this be added to the queue? private func autoDetectERC20Transactions() { + guard !isAutoDetectingERC20Transactions else { return } + isAutoDetectingERC20Transactions = true + + let server = session.server let wallet = session.account.address + let startBlock = Config.getLastFetchedErc20InteractionBlockNumber(session.server, wallet: wallet).flatMap { $0 + 1 } - GetContractInteractions().getErc20Interactions( - address: wallet, - server: session.server, - startBlock: startBlock - ) { [weak self] result in + GetContractInteractions(queue: self.queue).getErc20Interactions(address: wallet, server: server, startBlock: startBlock) { [weak self] result in guard let strongSelf = self else { return } + let blockNumbers = result.map(\.blockNumber) if let minBlockNumber = blockNumbers.min(), let maxBlockNumber = blockNumbers.max() { firstly { strongSelf.backFillErc20TransactionGroup(result, startBlock: minBlockNumber, endBlock: maxBlockNumber) - }.done { backFilledTransactions in - Config.setLastFetchedErc20InteractionBlockNumber(maxBlockNumber, server: strongSelf.session.server, wallet: wallet) + }.done(on: strongSelf.queue) { backFilledTransactions in + Config.setLastFetchedErc20InteractionBlockNumber(maxBlockNumber, server: server, wallet: wallet) + strongSelf.update(items: backFilledTransactions) + }.cauterize() + .finally { + strongSelf.isAutoDetectingERC20Transactions = false } } else { + strongSelf.isAutoDetectingERC20Transactions = false strongSelf.update(items: result) } } } - private func backFillErc20TransactionGroup(_ transactionsToFill: [Transaction], startBlock: Int, endBlock: Int) -> Promise<[Transaction]> { + private func backFillErc20TransactionGroup(_ transactionsToFill: [TransactionInstance], startBlock: Int, endBlock: Int) -> Promise<[TransactionInstance]> { return firstly { fetchTransactions(for: session.account.address, startBlock: startBlock, endBlock: endBlock, sortOrder: .asc) - }.map { fillerTransactions -> [Transaction] in - var results: [Transaction] = .init() + }.map(on: self.queue) { fillerTransactions -> [TransactionInstance] in + var results: [TransactionInstance] = .init() for each in transactionsToFill { //ERC20 transactions are expected to have operations because of the API we use to retrieve them from guard !each.localizedOperations.isEmpty else { continue } - if let transaction = fillerTransactions.first(where: { $0.blockNumber == each.blockNumber }) { + if var transaction = fillerTransactions.first(where: { $0.blockNumber == each.blockNumber }) { transaction.isERC20Interaction = true transaction.localizedOperations = each.localizedOperations results.append(transaction) @@ -120,94 +135,116 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData } func fetch() { - session.refresh(.balance) - fetchLatestTransactions() - fetchPendingTransactions() + self.queue.async { + DispatchQueue.main.async { + self.session.refresh(.balance) + } + + self.fetchLatestTransactions() + self.fetchPendingTransactions() + } } - private func update(items: [Transaction]) { - filterTransactionsToPullContractsFrom(items).done { transactionsToPullContractsFrom, contractsAndTokenTypes in + private func update(items: [TransactionInstance]) { + guard !items.isEmpty else { return } + + filterTransactionsToPullContractsFrom(items).done(on: self.queue, { transactionsToPullContractsFrom, contractsAndTokenTypes in self.storage.add(transactions: items, transactionsToPullContractsFrom: transactionsToPullContractsFrom, contractsAndTokenTypes: contractsAndTokenTypes) self.delegate?.handleUpdateItems(inCoordinator: self) - }.cauterize() + }).cauterize() } - private func filterTransactionsToPullContractsFrom(_ transactions: [Transaction]) -> Promise<(transactions: [Transaction], contractTypes: [AlphaWallet.Address: TokenType])> { + private var contractsToAvoid: [AlphaWallet.Address] { let alreadyAddedContracts = tokensStorage.enabledObject.map { $0.contractAddress } let deletedContracts = tokensStorage.deletedContracts.map { $0.contractAddress } let hiddenContracts = tokensStorage.hiddenContracts.map { $0.contractAddress } let delegateContracts = tokensStorage.delegateContracts.map { $0.contractAddress } - let contractsToAvoid = alreadyAddedContracts + deletedContracts + hiddenContracts + delegateContracts - let filteredTransactions = transactions.filter { - if let toAddressToCheck = AlphaWallet.Address(string: $0.to) { - if contractsToAvoid.contains(toAddressToCheck) { + + return alreadyAddedContracts + deletedContracts + hiddenContracts + delegateContracts + } + + private func filterTransactionsToPullContractsFrom(_ transactions: [TransactionInstance]) -> Promise<(transactions: [TransactionInstance], contractTypes: [AlphaWallet.Address: TokenType])> { + return Promise { seal in + let contractsToAvoid = self.contractsToAvoid + let filteredTransactions = transactions.filter { + if let toAddressToCheck = AlphaWallet.Address(string: $0.to), contractsToAvoid.contains(toAddressToCheck) { return false } - } - if let contractAddressToCheck = $0.operation?.contractAddress { - if contractsToAvoid.contains(contractAddressToCheck) { + if let contractAddressToCheck = $0.operation?.contractAddress, contractsToAvoid.contains(contractAddressToCheck) { return false } + return true } - return true - } - //The fetch ERC20 transactions endpoint from Etherscan returns only ERC20 token transactions but the Blockscout version also includes ERC721 transactions too (so it's likely other types that it can detect will be returned too); thus we check the token type rather than assume that they are all ERC20 - switch session.server { - case .xDai, .poa: - let contracts = Array(Set(filteredTransactions.compactMap { $0.localizedOperations.first?.contractAddress })) - let tokenTypePromises = contracts.map { tokensStorage.getTokenType(for: $0) } - return when(fulfilled: tokenTypePromises).map { tokenTypes in - let contractsToTokenTypes = Dictionary(uniqueKeysWithValues: zip(contracts, tokenTypes)) - return (transactions: filteredTransactions, contractTypes: contractsToTokenTypes) + + //The fetch ERC20 transactions endpoint from Etherscan returns only ERC20 token transactions but the Blockscout version also includes ERC721 transactions too (so it's likely other types that it can detect will be returned too); thus we check the token type rather than assume that they are all ERC20 + switch self.session.server { + case .xDai, .poa: + let contracts = Array(Set(filteredTransactions.compactMap { $0.localizedOperations.first?.contractAddress })) + let tokenTypePromises = contracts.map { self.tokensStorage.getTokenType(for: $0) } + + when(fulfilled: tokenTypePromises).map { tokenTypes in + let contractsToTokenTypes = Dictionary(uniqueKeysWithValues: zip(contracts, tokenTypes)) + return (transactions: filteredTransactions, contractTypes: contractsToTokenTypes) + }.done { val in + seal.fulfill(val) + }.catch { error in + seal.reject(error) + } + case .main, .classic, .kovan, .ropsten, .rinkeby, .sokol, .callisto, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet: + seal.fulfill((transactions: filteredTransactions, contractTypes: .init())) } - case .main, .classic, .kovan, .ropsten, .rinkeby, .sokol, .callisto, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet: - return .value((transactions: filteredTransactions, contractTypes: .init())) } } private func fetchPendingTransactions() { - storage.pendingObjects.forEach { updatePendingTransaction($0) } + storage.pendingObjects.forEach { + self.updatePendingTransaction($0) + } } - private func updatePendingTransaction(_ transaction: Transaction) { + private func updatePendingTransaction(_ transaction: TransactionInstance) { let request = GetTransactionRequest(hash: transaction.id) + firstly { Session.send(EtherServiceRequest(server: session.server, batch: BatchFactory().create(request))) }.done { _ in if transaction.date > Date().addingTimeInterval(TransactionDataCoordinator.delayedTransactionInternalSeconds) { - self.update(state: .completed, for: transaction) + //NOTE: We dont want to call function handleUpdateItems: twice because it will be updated in update(items: + self.update(state: .completed, for: transaction, shouldUpdateItems: false) self.update(items: [transaction]) } }.catch { error in - guard let error = error as? SessionTaskError else { return } - switch error { + switch error as? SessionTaskError { case .responseError(let error): // TODO: Think about the logic to handle pending transactions. - guard let error = error as? JSONRPCError else { return } - switch error { + switch error as? JSONRPCError { case .responseError: self.delete(transactions: [transaction]) case .resultObjectParseError: if transaction.date > Date().addingTimeInterval(TransactionDataCoordinator.deleteMissingInternalSeconds) { self.update(state: .failed, for: transaction) } - case .responseNotFound, .errorObjectParseError, .unsupportedVersion, .unexpectedTypeObject, .missingBothResultAndError, .nonArrayResponse: + case .responseNotFound, .errorObjectParseError, .unsupportedVersion, .unexpectedTypeObject, .missingBothResultAndError, .nonArrayResponse, .none: break } - case .connectionError, .requestError: + case .connectionError, .requestError, .none: break } } } - private func delete(transactions: [Transaction]) { - storage.delete(transactions) - delegate?.handleUpdateItems(inCoordinator: self) + private func delete(transactions: [TransactionInstance]) { + storage.delete(transactions: transactions).done(on: self.queue, { _ in + self.delegate?.handleUpdateItems(inCoordinator: self) + }).cauterize() } - private func update(state: TransactionState, for transaction: Transaction) { - storage.update(state: state, for: transaction) - delegate?.handleUpdateItems(inCoordinator: self) + private func update(state: TransactionState, for transaction: TransactionInstance, shouldUpdateItems: Bool = true) { + storage.update(state: state, for: transaction.primaryKey).done(on: self.queue, { _ in + guard shouldUpdateItems else { return } + + self.delegate?.handleUpdateItems(inCoordinator: self) + }).cauterize() } ///Fetching transactions might take a long time, we use a flag to make sure we only pull the latest transactions 1 "page" at a time, otherwise we'd end up pulling the same "page" multiple times @@ -215,16 +252,20 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData guard !isFetchingLatestTransactions else { return } isFetchingLatestTransactions = true + let value = storage.transactionObjectsThatDoNotComeFromEventLogs() + let startBlock: Int let sortOrder: AlphaWalletService.SortOrder - if let newestCachedTransaction = storage.transactionObjectsThatDoNotComeFromEventLogs.first { + + if let newestCachedTransaction = value { startBlock = newestCachedTransaction.blockNumber + 1 sortOrder = .asc } else { startBlock = 1 sortOrder = .desc } - let operation = FetchLatestTransactionsOperation(forSession: session, coordinator: self, startBlock: startBlock, sortOrder: sortOrder) + + let operation = FetchLatestTransactionsOperation(forSession: session, coordinator: self, startBlock: startBlock, sortOrder: sortOrder, queue: self.queue) fetchLatestTransactionsQueue.addOperation(operation) } @@ -234,47 +275,56 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData } //TODO notify user of received tokens too - private func notifyUserEtherReceived(inNewTransactions transactions: [Transaction]) { + private func notifyUserEtherReceived(inNewTransactions transactions: [TransactionInstance]) { guard !transactions.isEmpty else { return } + let wallet = keystore.currentWallet - var toNotify: [Transaction] - if let newestCached = storage.objects.first { + + let objects = storage.transactions + var toNotify: [TransactionInstance] + + if let newestCached = objects.first { toNotify = transactions.filter { $0.blockNumber > newestCached.blockNumber } } else { toNotify = transactions } + //Beyond a certain number, it's too noisy and a performance nightmare. Eg. the first time we fetch transactions for a newly imported wallet, we might get 10,000 of them let maximumNumberOfNotifications = 10 if toNotify.count > maximumNumberOfNotifications { toNotify = Array(toNotify[0.. thresholdToShowNotification { - notifyUserEtherReceived(for: each.id, amount: amount) + self.notifyUserEtherReceived(for: each.id, amount: amount) } } let etherReceivedUsedForBackupPrompt = newIncomingEthTransactions .last { wallet.address.sameContract(as: $0.to) } .flatMap { BigInt($0.value) } + switch session.server { //TODO make this work for other mainnets case .main: - etherReceivedUsedForBackupPrompt.flatMap { promptBackupCoordinator.showCreateBackupAfterReceiveNativeCryptoCurrencyPrompt(nativeCryptoCurrency: $0) } + etherReceivedUsedForBackupPrompt.flatMap { + self.promptBackupCoordinator.showCreateBackupAfterReceiveNativeCryptoCurrencyPrompt(nativeCryptoCurrency: $0) + } case .classic, .xDai: break case .kovan, .ropsten, .rinkeby, .poa, .sokol, .callisto, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet: break } + } //Etherscan for Ropsten returns the same transaction twice. Normally Realm will take care of this, but since we are showing user a notification, we don't want to show duplicates - private func filterUniqueTransactions(_ transactions: [Transaction]) -> [Transaction] { - var results = [Transaction]() + private func filterUniqueTransactions(_ transactions: [TransactionInstance]) -> [TransactionInstance] { + var results = [TransactionInstance]() for each in transactions { if !results.contains(where: { each.id == $0.id }) { results.append(each) @@ -295,10 +345,13 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData content.sound = .default let identifier = Constants.etherReceivedNotificationIdentifier let request = UNNotificationRequest(identifier: "\(identifier):\(transactionId)", content: content, trigger: nil) - notificationCenter.add(request) + + DispatchQueue.main.async { + notificationCenter.add(request) + } } - private func fetchTransactions(for address: AlphaWallet.Address, startBlock: Int, endBlock: Int = 999_999_999, sortOrder: AlphaWalletService.SortOrder) -> Promise<[Transaction]> { + private func fetchTransactions(for address: AlphaWallet.Address, startBlock: Int, endBlock: Int = 999_999_999, sortOrder: AlphaWalletService.SortOrder) -> Promise<[TransactionInstance]> { return alphaWalletProvider.request(.getTransactions( config: session.config, @@ -307,12 +360,13 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData startBlock: startBlock, endBlock: endBlock, sortOrder: sortOrder - )).map { + )) + .map(on: self.queue) { try $0.map(ArrayResponse.self).result.map { - Transaction.from(transaction: $0, tokensStorage: self.tokensStorage) + TransactionInstance.from(transaction: $0, tokensStorage: self.tokensStorage) } - }.then { - when(fulfilled: $0).compactMap { + }.then(on: self.queue) { + when(fulfilled: $0).compactMap(on: self.queue) { $0.compactMap { $0 } } } @@ -322,19 +376,23 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData guard let oldestCachedTransaction = storage.completedObjects.last else { return } let promise = fetchTransactions(for: address, startBlock: 1, endBlock: oldestCachedTransaction.blockNumber - 1, sortOrder: .desc) - promise.done { [weak self] transactions in - self?.update(items: transactions) + promise.done(on: self.queue, { [weak self] transactions in + guard let strongSelf = self else { return } - if !transactions.isEmpty { + strongSelf.update(items: transactions) + + if transactions.isEmpty { + strongSelf.transactionsTracker.fetchingState = .done + } else { let timeout = DispatchTime.now() + .milliseconds(300) - DispatchQueue.main.asyncAfter(deadline: timeout) { [weak self] in - self?.fetchOlderTransactions(for: address) + strongSelf.queue.asyncAfter(deadline: timeout) { + strongSelf.fetchOlderTransactions(for: address) } - } else { - self?.transactionsTracker.fetchingState = .done } - }.catch { [weak self] _ in - self?.transactionsTracker.fetchingState = .failed + }).catch(on: self.queue) { [weak self] _ in + guard let strongSelf = self else { return } + + strongSelf.transactionsTracker.fetchingState = .failed } } @@ -365,31 +423,38 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData override var isAsynchronous: Bool { return true } + private let queue: DispatchQueue - init(forSession session: WalletSession, coordinator: SingleChainTransactionEtherscanDataCoordinator, startBlock: Int, sortOrder: AlphaWalletService.SortOrder) { + init(forSession session: WalletSession, coordinator: SingleChainTransactionEtherscanDataCoordinator, startBlock: Int, sortOrder: AlphaWalletService.SortOrder, queue: DispatchQueue) { self.session = session self.coordinator = coordinator self.startBlock = startBlock self.sortOrder = sortOrder + self.queue = queue super.init() self.queuePriority = session.server.networkRequestsQueuePriority } override func main() { guard let coordinator = self.coordinator else { return } + firstly { coordinator.fetchTransactions(for: session.account.address, startBlock: startBlock, sortOrder: sortOrder) - }.done { transactions in + }.done(on: queue, { transactions in coordinator.notifyUserEtherReceived(inNewTransactions: transactions) coordinator.update(items: transactions) - }.catch { e in + }).catch { e in coordinator.handleError(error: e) }.finally { [weak self] in - self?.willChangeValue(forKey: "isExecuting") - self?.willChangeValue(forKey: "isFinished") + guard let strongSelf = self else { return } + + strongSelf.willChangeValue(forKey: "isExecuting") + strongSelf.willChangeValue(forKey: "isFinished") + coordinator.isFetchingLatestTransactions = false - self?.didChangeValue(forKey: "isExecuting") - self?.didChangeValue(forKey: "isFinished") + + strongSelf.didChangeValue(forKey: "isExecuting") + strongSelf.didChangeValue(forKey: "isFinished") } } } diff --git a/AlphaWallet/Transactions/Coordinators/TransactionCoordinator.swift b/AlphaWallet/Transactions/Coordinators/TransactionCoordinator.swift index 540f5363f..6a396d58d 100644 --- a/AlphaWallet/Transactions/Coordinators/TransactionCoordinator.swift +++ b/AlphaWallet/Transactions/Coordinators/TransactionCoordinator.swift @@ -125,4 +125,4 @@ extension TransactionCoordinator: CanOpenURL { } extension TransactionCoordinator: TransactionViewControllerDelegate { -} \ No newline at end of file +} diff --git a/AlphaWallet/Transactions/Coordinators/TransactionDataCoordinator.swift b/AlphaWallet/Transactions/Coordinators/TransactionDataCoordinator.swift index f8af36ca7..fdf115855 100644 --- a/AlphaWallet/Transactions/Coordinators/TransactionDataCoordinator.swift +++ b/AlphaWallet/Transactions/Coordinators/TransactionDataCoordinator.swift @@ -8,7 +8,7 @@ enum TransactionError: Error { } protocol TransactionDataCoordinatorDelegate: class { - func didUpdate(result: ResultResult<[Transaction], TransactionError>.t, reloadImmediately: Bool) + func didUpdate(result: ResultResult<[TransactionInstance], TransactionError>.t, reloadImmediately: Bool) } class TransactionDataCoordinator: Coordinator { @@ -72,11 +72,10 @@ class TransactionDataCoordinator: Coordinator { for each in singleChainTransactionDataCoordinators { each.start() } + //Since start() is called at launch, and user don't see the Transactions tab immediately, we don't want it to block launching DispatchQueue.global().async { - DispatchQueue.main.async { [weak self] in - self?.handleUpdateItems(reloadImmediately: false) - } + self.handleUpdateItems(reloadImmediately: false) } } @@ -87,11 +86,8 @@ class TransactionDataCoordinator: Coordinator { } @objc private func restartTimers() { - runScheduledTimers() - } - - private func runScheduledTimers() { guard !config.isAutoFetchingDisabled else { return } + for each in singleChainTransactionDataCoordinators { each.runScheduledTimers() } @@ -99,6 +95,7 @@ class TransactionDataCoordinator: Coordinator { func fetch() { guard !config.isAutoFetchingDisabled else { return } + for each in singleChainTransactionDataCoordinators { each.fetch() } @@ -110,6 +107,7 @@ class TransactionDataCoordinator: Coordinator { let tokensDataStore = tokensStorages[transaction.original.server] let transaction = Transaction.from(from: session.account.address, transaction: transaction, tokensDataStore: tokensDataStore) transactionCollection.add([transaction]) + handleUpdateItems(reloadImmediately: true) } @@ -124,13 +122,15 @@ class TransactionDataCoordinator: Coordinator { } private func handleUpdateItems(reloadImmediately: Bool) { - delegate?.didUpdate(result: .success(transactionCollection.objects), reloadImmediately: reloadImmediately) - delegate2?.didUpdate(result: .success(transactionCollection.objects), reloadImmediately: reloadImmediately) + let objects = transactionCollection.objects + + delegate?.didUpdate(result: .success(objects), reloadImmediately: reloadImmediately) + delegate2?.didUpdate(result: .success(objects), reloadImmediately: reloadImmediately) } } extension TransactionDataCoordinator: SingleChainTransactionDataCoordinatorDelegate { - func handleUpdateItems(inCoordinator: SingleChainTransactionDataCoordinator) { + func handleUpdateItems(inCoordinator coordinator: SingleChainTransactionDataCoordinator) { handleUpdateItems(reloadImmediately: false) } } diff --git a/AlphaWallet/Transactions/Storage/Transaction.swift b/AlphaWallet/Transactions/Storage/Transaction.swift index 396678743..9cab0fbd1 100644 --- a/AlphaWallet/Transactions/Storage/Transaction.swift +++ b/AlphaWallet/Transactions/Storage/Transaction.swift @@ -39,7 +39,6 @@ class Transaction: Object { state: TransactionState, isErc20Interaction: Bool ) { - self.init() self.primaryKey = "\(id)-\(server.chainID)" self.id = id @@ -65,6 +64,34 @@ class Transaction: Object { self.localizedOperations = list } + convenience init(object: TransactionInstance) { + self.init() + + self.primaryKey = object.primaryKey + self.id = object.id + self.chainId = object.server.chainID + self.blockNumber = object.blockNumber + self.transactionIndex = object.transactionIndex + self.from = object.from + self.to = object.to + self.value = object.value + self.gas = object.gas + self.gasPrice = object.gasPrice + self.gasUsed = object.gasUsed + self.nonce = object.nonce + self.date = object.date + self.internalState = object.state.rawValue + self.isERC20Interaction = object.isERC20Interaction + + let list = List() + object.localizedOperations.forEach { element in + let value = LocalizedOperationObject(object: element) + list.append(value) + } + + self.localizedOperations = list + } + override static func primaryKey() -> String? { return "primaryKey" } @@ -87,6 +114,7 @@ extension Transaction { extension Transaction { static func from(from: AlphaWallet.Address, transaction: SentTransaction, tokensDataStore: TokensDataStore) -> Transaction { let (operations: operations, isErc20Interaction: isErc20Interaction) = decodeOperations(fromData: transaction.original.data, from: transaction.original.account, contractOrRecipient: transaction.original.to, tokensDataStore: tokensDataStore) + return Transaction( id: transaction.id, server: transaction.original.server, @@ -108,7 +136,7 @@ extension Transaction { //TODO add support for more types of pending transactions fileprivate static func decodeOperations(fromData data: Data, from: AlphaWallet.Address, contractOrRecipient: AlphaWallet.Address?, tokensDataStore: TokensDataStore) -> (operations: [LocalizedOperationObject], isErc20Interaction: Bool) { - if let functionCallMetaData = DecodedFunctionCall(data: data), let contract = contractOrRecipient, let token = tokensDataStore.token(forContract: contract) { + if let functionCallMetaData = DecodedFunctionCall(data: data), let contract = contractOrRecipient, let token = tokensDataStore.tokenThreadSafe(forContract: contract) { switch functionCallMetaData.type { case .erc20Transfer(let recipient, let value): return (operations: [LocalizedOperationObject(from: from.eip55String, to: recipient.eip55String, contract: contract, type: OperationType.erc20TokenTransfer.rawValue, value: String(value), symbol: token.symbol, name: token.name, decimals: token.decimals)], isErc20Interaction: true) @@ -118,4 +146,92 @@ extension Transaction { } return (operations: .init(), isErc20Interaction: false) } -} \ No newline at end of file +} + +struct TransactionInstance { + var primaryKey: String = "" + var chainId: Int = 0 + var id: String = "" + var blockNumber: Int = 0 + var transactionIndex: Int = 0 + var from = "" + var to = "" + var value = "" + var gas = "" + var gasPrice = "" + var gasUsed = "" + var nonce: String = "" + var date = Date() + var internalState: Int = TransactionState.completed.rawValue + var isERC20Interaction: Bool = false + var localizedOperations: [LocalizedOperationObjectInstance] = [] + + init( + id: String, + server: RPCServer, + blockNumber: Int, + transactionIndex: Int, + from: String, + to: String, + value: String, + gas: String, + gasPrice: String, + gasUsed: String, + nonce: String, + date: Date, + localizedOperations: [LocalizedOperationObjectInstance], + state: TransactionState, + isErc20Interaction: Bool + ) { + + self.primaryKey = "\(id)-\(server.chainID)" + self.id = id + self.chainId = server.chainID + self.blockNumber = blockNumber + self.transactionIndex = transactionIndex + self.from = from + self.to = to + self.value = value + self.gas = gas + self.gasPrice = gasPrice + self.gasUsed = gasUsed + self.nonce = nonce + self.date = date + self.internalState = state.rawValue + self.isERC20Interaction = isErc20Interaction + self.localizedOperations = localizedOperations + } + + init(transaction: Transaction) { + self.primaryKey = transaction.primaryKey + self.id = transaction.id + self.chainId = transaction.server.chainID + self.blockNumber = transaction.blockNumber + self.transactionIndex = transaction.transactionIndex + self.from = transaction.from + self.to = transaction.to + self.value = transaction.value + self.gas = transaction.gas + self.gasPrice = transaction.gasPrice + self.gasUsed = transaction.gasUsed + self.nonce = transaction.nonce + self.date = transaction.date + self.internalState = transaction.state.rawValue + self.isERC20Interaction = transaction.isERC20Interaction + self.localizedOperations = transaction.localizedOperations.map { + LocalizedOperationObjectInstance(object: $0) + } + } + + var state: TransactionState { + return TransactionState(int: internalState) + } + + var operation: LocalizedOperationObjectInstance? { + return localizedOperations.first + } + + var server: RPCServer { + return .init(chainID: chainId) + } +} diff --git a/AlphaWallet/Transactions/Storage/TransactionsStorage.swift b/AlphaWallet/Transactions/Storage/TransactionsStorage.swift index e0bee1cb0..9e31a29e0 100644 --- a/AlphaWallet/Transactions/Storage/TransactionsStorage.swift +++ b/AlphaWallet/Transactions/Storage/TransactionsStorage.swift @@ -1,5 +1,6 @@ import Foundation import RealmSwift +import PromiseKit protocol TransactionsStorageDelegate: class { func didAddTokensWith(contracts: [AlphaWallet.Address], inTransactionsStorage: TransactionsStorage) @@ -21,35 +22,35 @@ class TransactionsStorage { return objects.count } - var objects: [Transaction] { - return Array(realm.objects(Transaction.self) - .sorted(byKeyPath: "date", ascending: false) - .filter("chainId = \(self.server.chainID)") - .filter("id != ''")) + var objects: Results { + realm.threadSafe.objects(Transaction.self) + .sorted(byKeyPath: "date", ascending: false) + .filter("chainId = \(self.server.chainID)") + .filter("id != ''") } var completedObjects: [Transaction] { - return objects.filter { $0.state == .completed } + objects.filter { $0.state == .completed } } - var transactionObjectsThatDoNotComeFromEventLogs: Results { - return realm.objects(Transaction.self) - .sorted(byKeyPath: "date", ascending: false) - .filter("chainId = \(self.server.chainID)") - .filter("id != ''") - .filter("internalState == \(TransactionState.completed.rawValue)") - .filter("isERC20Interaction == false") + var pendingObjects: [TransactionInstance] { + objects.filter { $0.state == TransactionState.pending }.map { TransactionInstance(transaction: $0) } } - var pendingObjects: [Transaction] { - return objects.filter { $0.state == TransactionState.pending } + func transaction(withTransactionId transactionId: String) -> TransactionInstance? { + realm.threadSafe.objects(Transaction.self) + .filter("id = '\(transactionId)'") + .filter("chainId = \(server.chainID)") + .map { TransactionInstance(transaction: $0) } + .first } - func transaction(withTransactionId transactionId: String) -> Transaction? { - realm.objects(Transaction.self) - .filter("id = '\(transactionId)'") - .filter("chainId = \(server.chainID)") - .first + private func addTokensWithContractAddresses(fromTransactions transactions: [Transaction], contractsAndTokenTypes: [AlphaWallet.Address: TokenType], realm: Realm) { + let tokens = self.tokens(from: transactions, contractsAndTokenTypes: contractsAndTokenTypes) + delegate?.didAddTokensWith(contracts: Array(Set(tokens.map { $0.address })), inTransactionsStorage: self) + if !tokens.isEmpty { + TokensDataStore.update(in: realm, tokens: tokens) + } } private func addTokensWithContractAddresses(fromTransactions transactions: [Transaction], contractsAndTokenTypes: [AlphaWallet.Address: TokenType]) { @@ -62,15 +63,100 @@ class TransactionsStorage { func add(transactions: [Transaction], transactionsToPullContractsFrom: [Transaction], contractsAndTokenTypes: [AlphaWallet.Address: TokenType]) { guard !transactions.isEmpty else { return } - let transactionsToCommit = filterTransactionsToNotOverrideERC20Transactions(transactions) + let transactionsToCommit = filterTransactionsToNotOverrideERC20Transactions(transactions, realm: realm) realm.beginWrite() realm.add(transactionsToCommit, update: .all) + try! realm.commitWrite() addTokensWithContractAddresses(fromTransactions: transactionsToPullContractsFrom, contractsAndTokenTypes: contractsAndTokenTypes) } + func transactionObjectsThatDoNotComeFromEventLogs() -> TransactionInstance? { + return realm.threadSafe.objects(Transaction.self) + .sorted(byKeyPath: "date", ascending: false) + .filter("chainId = \(self.server.chainID)") + .filter("id != ''") + .filter("internalState == \(TransactionState.completed.rawValue)") + .filter("isERC20Interaction == false") + .map { TransactionInstance(transaction: $0) } + .first + } + + var transactions: [TransactionInstance] { + realm.threadSafe.objects(Transaction.self) + .sorted(byKeyPath: "date", ascending: false) + .filter("chainId = \(self.server.chainID)") + .filter("id != ''") + .map { TransactionInstance(transaction: $0) } + } + + func delete(transactions: [TransactionInstance]) -> Promise { + return Promise { seal in + let realm = self.realm.threadSafe + let objects = transactions.compactMap { + realm.object(ofType: Transaction.self, forPrimaryKey: $0.primaryKey) + } + + do { + try realm.write { + realm.delete(objects) + } + seal.fulfill(()) + } catch { + seal.reject(error) + } + } + } + + func update(state: TransactionState, for primaryKey: String) -> Promise { + enum AnyError: Error { + case invalid + } + + return Promise { seal in + let realm = self.realm.threadSafe + if let value = realm.object(ofType: Transaction.self, forPrimaryKey: primaryKey) { + realm.beginWrite() + + value.internalState = state.rawValue + + do { + try realm.commitWrite() + let transaction = TransactionInstance(transaction: value) + + seal.fulfill(transaction) + } catch { + seal.reject(error) + } + } else { + seal.reject(AnyError.invalid) + } + } + } + + func add(transactions: [TransactionInstance], transactionsToPullContractsFrom: [TransactionInstance], contractsAndTokenTypes: [AlphaWallet.Address: TokenType]) { + + guard !transactions.isEmpty else { return } + + let newTransactions = transactions.map { Transaction(object: $0) } + let newTransactionsToPullContractsFrom = transactionsToPullContractsFrom.map { Transaction(object: $0) } + + let realm = self.realm.threadSafe + + let transactionsToCommit = self.filterTransactionsToNotOverrideERC20Transactions(newTransactions, realm: realm) + realm.beginWrite() + + for transaction in transactionsToCommit { + realm.add(transaction, update: .all) + } + + try! realm.commitWrite() + + self.addTokensWithContractAddresses(fromTransactions: newTransactionsToPullContractsFrom, contractsAndTokenTypes: contractsAndTokenTypes, realm: realm) + } + //We pull transactions data from the normal transactions API as well as ERC20 event log. For the same transaction, we only want data from the latter. Otherwise the UI will show the cell display switching between data from the 2 source as we fetch (or re-fetch) - private func filterTransactionsToNotOverrideERC20Transactions(_ transactions: [Transaction]) -> [Transaction] { + private func filterTransactionsToNotOverrideERC20Transactions(_ transactions: [Transaction], realm: Realm) -> [Transaction] { return transactions.filter { each in if each.isERC20Interaction { return true diff --git a/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift b/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift index 5d5a25845..0862dccb3 100644 --- a/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift +++ b/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift @@ -35,6 +35,88 @@ class LocalizedOperationObject: Object { self.decimals = decimals } + convenience init(object: LocalizedOperationObjectInstance) { + self.init() + self.from = object.from + self.to = object.to + self.contract = object.contract + self.type = object.type + self.value = object.value + self.symbol = object.symbol + self.name = object.name + self.decimals = object.decimals + } + + var operationType: OperationType { + return OperationType(string: type) + } + + var contractAddress: AlphaWallet.Address? { + return contract.flatMap { AlphaWallet.Address(uncheckedAgainstNullAddress: $0) } + } +} + +extension LocalizedOperationObject { + static func from(operations: [LocalizedOperation]?) -> [LocalizedOperationObject] { + guard let operations = operations else { return [] } + return operations.compactMap { operation in + guard let from = operation.fromAddress, let to = operation.toAddress else { return nil } + return LocalizedOperationObject( + from: from.description, + to: to.description, + contract: operation.contract.contractAddress, + type: operation.type.rawValue, + value: operation.value, + symbol: operation.contract.symbol, + name: operation.contract.name, + decimals: operation.contract.decimals + ) + } + } +} + +struct LocalizedOperationObjectInstance { + //TODO good to have getters/setter computed properties for `from` and `to` too that is typed AlphaWallet.Address. But have to be careful and check if they can be empty or "0x" + var from: String = "" + var to: String = "" + var contract: String? = .none + var type: String = "" + var value: String = "" + var name: String? = .none + var symbol: String? = .none + var decimals: Int = 18 + + init(object: LocalizedOperationObject) { + self.from = object.from + self.to = object.to + self.contract = object.contract + self.type = object.type + self.value = object.value + self.symbol = object.symbol + self.name = object.name + self.decimals = object.decimals + } + + init( + from: String, + to: String, + contract: AlphaWallet.Address?, + type: String, + value: String, + symbol: String?, + name: String?, + decimals: Int + ) { + self.from = from + self.to = to + self.contract = contract?.eip55String + self.type = type + self.value = value + self.symbol = symbol + self.name = name + self.decimals = decimals + } + var operationType: OperationType { return OperationType(string: type) } @@ -42,4 +124,4 @@ class LocalizedOperationObject: Object { var contractAddress: AlphaWallet.Address? { return contract.flatMap { AlphaWallet.Address(uncheckedAgainstNullAddress: $0) } } -} \ No newline at end of file +} diff --git a/AlphaWallet/Transactions/ViewControllers/TransactionViewController.swift b/AlphaWallet/Transactions/ViewControllers/TransactionViewController.swift index 45bfc48ec..8ac9ee0bc 100644 --- a/AlphaWallet/Transactions/ViewControllers/TransactionViewController.swift +++ b/AlphaWallet/Transactions/ViewControllers/TransactionViewController.swift @@ -25,11 +25,7 @@ class TransactionViewController: UIViewController { weak var delegate: TransactionViewControllerDelegate? - init( - session: WalletSession, - transactionRow: TransactionRow, - delegate: TransactionViewControllerDelegate? - ) { + init(session: WalletSession, transactionRow: TransactionRow, delegate: TransactionViewControllerDelegate?) { self.session = session self.transactionRow = transactionRow self.delegate = delegate @@ -172,7 +168,7 @@ class TransactionViewController: UIViewController { } required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + return nil } @objc func dismiss() { diff --git a/AlphaWallet/Transactions/ViewControllers/TransactionsViewController.swift b/AlphaWallet/Transactions/ViewControllers/TransactionsViewController.swift index b56a7886b..a897342a0 100644 --- a/AlphaWallet/Transactions/ViewControllers/TransactionsViewController.swift +++ b/AlphaWallet/Transactions/ViewControllers/TransactionsViewController.swift @@ -84,12 +84,7 @@ class TransactionsViewController: UIViewController { func fetch() { startLoading() - //Since this is called at launch, we don't want it to block launching - DispatchQueue.global().async { - DispatchQueue.main.async { [weak self] in - self?.dataCoordinator.fetch() - } - } + dataCoordinator.fetch() } func configure(viewModel: TransactionsViewModel) { @@ -97,8 +92,9 @@ class TransactionsViewController: UIViewController { } required init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") + return nil } + fileprivate func headerView(for section: Int) -> UIView { let container = UIView() container.backgroundColor = viewModel.headerBackgroundColor @@ -109,6 +105,7 @@ class TransactionsViewController: UIViewController { title.font = viewModel.headerTitleFont container.addSubview(title) title.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ title.anchorsConstraint(to: container, edgeInsets: .init(top: 18, left: 20, bottom: 16, right: 0)) ]) @@ -130,15 +127,26 @@ extension TransactionsViewController: UITableViewDelegate { } extension TransactionsViewController: TransactionDataCoordinatorDelegate { - func didUpdate(result: Result<[Transaction], TransactionError>, reloadImmediately: Bool) { + func didUpdate(result: Result<[TransactionInstance], TransactionError>, reloadImmediately: Bool) { switch result { case .success(let items): - let viewModel = TransactionsViewModel(transactions: items) - configure(viewModel: viewModel) - endLoading() + //NOTE: avoid filtering events on main queue + let values = TransactionsViewModel.mapTransactions(transactions: items) + DispatchQueue.main.async { + self.configure(viewModel: .init(transactions: values)) + + self.endLoading() + self.reloadTableViewAndEndRefreshing() + } case .failure(let error): - endLoading(error: error) + DispatchQueue.main.async { + self.endLoading(error: error) + self.reloadTableViewAndEndRefreshing() + } } + } + + private func reloadTableViewAndEndRefreshing() { tableView.reloadData() if refreshControl.isRefreshing { @@ -156,13 +164,9 @@ extension TransactionsViewController: UITableViewDataSource { let transactionRow = viewModel.item(for: indexPath.row, section: indexPath.section) let cell: TransactionViewCell = tableView.dequeueReusableCell(for: indexPath) let session = sessions[transactionRow.server] - cell.configure(viewModel: .init( - transactionRow: transactionRow, - chainState: session.chainState, - currentWallet: session.account, - server: transactionRow.server - ) - ) + let viewModel: TransactionRowCellViewModel = .init(transactionRow: transactionRow, chainState: session.chainState, currentWallet: session.account, server: transactionRow.server) + cell.configure(viewModel: viewModel) + return cell } func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { diff --git a/AlphaWallet/Transactions/ViewModels/TransactionRow.swift b/AlphaWallet/Transactions/ViewModels/TransactionRow.swift index 7f2114b9a..b7a9edfbc 100644 --- a/AlphaWallet/Transactions/ViewModels/TransactionRow.swift +++ b/AlphaWallet/Transactions/ViewModels/TransactionRow.swift @@ -2,11 +2,11 @@ import Foundation enum TransactionRow { - case standalone(Transaction) - case group(Transaction) - case item(transaction: Transaction, operation: LocalizedOperationObject) + case standalone(TransactionInstance) + case group(TransactionInstance) + case item(transaction: TransactionInstance, operation: LocalizedOperationObjectInstance) - var transaction: Transaction { + var transaction: TransactionInstance { switch self { case .standalone(let transaction), .group(let transaction), .item(transaction: let transaction, _): return transaction @@ -74,7 +74,7 @@ enum TransactionRow { transaction.server } - var operation: LocalizedOperationObject? { + var operation: LocalizedOperationObjectInstance? { switch self { case .standalone(let transaction): return transaction.operation @@ -84,4 +84,4 @@ enum TransactionRow { return operation } } -} \ No newline at end of file +} diff --git a/AlphaWallet/Transactions/ViewModels/TransactionRowCellViewModel.swift b/AlphaWallet/Transactions/ViewModels/TransactionRowCellViewModel.swift index 9471ef7ca..d39bf7bee 100644 --- a/AlphaWallet/Transactions/ViewModels/TransactionRowCellViewModel.swift +++ b/AlphaWallet/Transactions/ViewModels/TransactionRowCellViewModel.swift @@ -29,7 +29,7 @@ struct TransactionRowCellViewModel { } private var operationTitle: String? { - let operation: LocalizedOperationObject? + let operation: LocalizedOperationObjectInstance? switch transactionRow { case .standalone(let transaction): operation = transaction.operation diff --git a/AlphaWallet/Transactions/ViewModels/TransactionsViewModel.swift b/AlphaWallet/Transactions/ViewModels/TransactionsViewModel.swift index db027e360..5fcb59182 100644 --- a/AlphaWallet/Transactions/ViewModels/TransactionsViewModel.swift +++ b/AlphaWallet/Transactions/ViewModels/TransactionsViewModel.swift @@ -4,12 +4,16 @@ import Foundation import UIKit struct TransactionsViewModel { - private var formatter: DateFormatter { + private static var formatter: DateFormatter { return Date.formatter(with: "dd MMM yyyy") } private var items: [(date: String, transactionRows: [TransactionRow])] = [] - init(transactions: [Transaction] = []) { + init(transactions: [(date: String, transactionRows: [TransactionRow])] = []) { + self.items = transactions + } + + static func mapTransactions(transactions: [TransactionInstance]) -> [(date: String, transactionRows: [TransactionRow])] { //Uses NSMutableArray instead of Swift array for performance. Really slow when dealing with 10k events, which is hardly a big wallet var newItems: [String: NSMutableArray] = [:] for transaction in transactions { @@ -19,12 +23,13 @@ struct TransactionsViewModel { newItems[date] = currentItems } let tuple = newItems.map { each in - (date: each.key, transactions: (each.value as! [Transaction]).sorted { $0.date > $1.date }) + (date: each.key, transactions: (each.value as! [TransactionInstance]).sorted { $0.date > $1.date }) } - let collapsedTransactions: [(date: String, transactions: [Transaction])] = tuple.sorted { (object1, object2) -> Bool in + let collapsedTransactions: [(date: String, transactions: [TransactionInstance])] = tuple.sorted { (object1, object2) -> Bool in return formatter.date(from: object1.date)! > formatter.date(from: object2.date)! } - items = collapsedTransactions.map { date, transactions in + + return collapsedTransactions.map { date, transactions in var items: [TransactionRow] = .init() for each in transactions { if each.localizedOperations.isEmpty { @@ -70,7 +75,7 @@ struct TransactionsViewModel { func titleForHeader(in section: Int) -> String { let value = items[section].date - let date = formatter.date(from: value)! + let date = Self.formatter.date(from: value)! if NSCalendar.current.isDateInToday(date) { return R.string.localizable.today().localizedUppercase } @@ -79,4 +84,4 @@ struct TransactionsViewModel { } return value.localizedUppercase } -} \ No newline at end of file +} diff --git a/AlphaWallet/UI/TokenObject+UI.swift b/AlphaWallet/UI/TokenObject+UI.swift index 22d59ea84..f2fef7836 100644 --- a/AlphaWallet/UI/TokenObject+UI.swift +++ b/AlphaWallet/UI/TokenObject+UI.swift @@ -74,27 +74,31 @@ private class TokenImageFetcher { subscribable.value = programmaticallyGenerateIcon(forToken: tokenObject) return subscribable } + + let githubAssetsSource = tokenObject.server.githubAssetsSource + let contractAddress = tokenObject.contractAddress + let balance = tokenObject.balance.first?.balance + let generatedImage = programmaticallyGenerateIcon(forToken: tokenObject) - fetchFromOpenSea(tokenObject).done { + fetchFromOpenSea(tokenObject.type, balance: balance).done { subscribable.value = (image: $0, symbol: "") }.catch { [weak self] _ in guard let strongSelf = self else { return } - strongSelf.fetchFromAssetGitHubRepo(tokenObject).done { + strongSelf.fetchFromAssetGitHubRepo(githubAssetsSource, contractAddress: contractAddress).done { subscribable.value = (image: $0, symbol: "") - }.catch { [weak self] _ in - guard let strongSelf = self else { return } - subscribable.value = strongSelf.programmaticallyGenerateIcon(forToken: tokenObject) + }.catch { _ in + subscribable.value = generatedImage } } return subscribable } - private func fetchFromOpenSea(_ tokenObject: TokenObject) -> Promise { + private func fetchFromOpenSea(_ type: TokenType, balance: String?) -> Promise { Promise { seal in - switch tokenObject.type { + switch type { case .erc721: - if let json = tokenObject.balance.first?.balance, let data = json.data(using: .utf8), let openSeaNonFungible = try? JSONDecoder().decode(OpenSeaNonFungible.self, from: data), !openSeaNonFungible.contractImageUrl.isEmpty { + if let json = balance, let data = json.data(using: .utf8), let openSeaNonFungible = try? JSONDecoder().decode(OpenSeaNonFungible.self, from: data), !openSeaNonFungible.contractImageUrl.isEmpty { let request = URLRequest(url: URL(string: openSeaNonFungible.contractImageUrl)!) fetch(request: request).done { image in seal.fulfill(image) @@ -108,8 +112,8 @@ private class TokenImageFetcher { } } - private func fetchFromAssetGitHubRepo(_ tokenObject: TokenObject) -> Promise { - return GithubAssetsURLResolver().resolve(for: tokenObject).then { request -> Promise in + private func fetchFromAssetGitHubRepo(_ githubAssetsSource: GithubAssetsURLResolver.Source, contractAddress: AlphaWallet.Address) -> Promise { + return GithubAssetsURLResolver().resolve(for: githubAssetsSource, contractAddress: contractAddress).then { request -> Promise in self.fetch(request: request) } } @@ -145,8 +149,8 @@ class GithubAssetsURLResolver { case case1 } - func resolve(for tokenObject: TokenObject) -> Promise { - let value = tokenObject.server.githubAssetsSource.rawValue + tokenObject.contractAddress.eip55String + "/" + GithubAssetsURLResolver.file + func resolve(for githubAssetsSource: GithubAssetsURLResolver.Source, contractAddress: AlphaWallet.Address) -> Promise { + let value = githubAssetsSource.rawValue + contractAddress.eip55String + "/" + GithubAssetsURLResolver.file guard let url = URL(string: value) else { return .init(error: AnyError.case1) diff --git a/AlphaWalletTests/Factories/Transaction.swift b/AlphaWalletTests/Factories/Transaction.swift index 76aef98a8..cd9f94507 100644 --- a/AlphaWalletTests/Factories/Transaction.swift +++ b/AlphaWalletTests/Factories/Transaction.swift @@ -38,3 +38,39 @@ extension Transaction { ) } } + +extension TransactionInstance { + static func make( + id: String = "0x1", + blockNumber: Int = 1, + transactionIndex: Int = 0, + from: String = "0x1", + to: String = "0x1", + value: String = "1", + gas: String = "0x1", + gasPrice: String = "0x1", + gasUsed: String = "0x1", + nonce: String = "0", + date: Date = Date(), + localizedOperations: [LocalizedOperationObjectInstance] = [], + state: TransactionState = .completed + ) -> TransactionInstance { + return TransactionInstance( + id: id, + server: .main, + blockNumber: blockNumber, + transactionIndex: transactionIndex, + from: from, + to: to, + value: value, + gas: gas, + gasPrice: gasPrice, + gasUsed: gasUsed, + nonce: nonce, + date: date, + localizedOperations: localizedOperations, + state: state, + isErc20Interaction: false + ) + } +} diff --git a/AlphaWalletTests/TokenScriptClient/FakeEventsDataStore.swift b/AlphaWalletTests/TokenScriptClient/FakeEventsDataStore.swift index 753861216..354a7c4f2 100644 --- a/AlphaWalletTests/TokenScriptClient/FakeEventsDataStore.swift +++ b/AlphaWalletTests/TokenScriptClient/FakeEventsDataStore.swift @@ -1,20 +1,23 @@ // Copyright © 2020 Stormbird PTE. LTD. import Foundation +import PromiseKit @testable import AlphaWallet class FakeEventsDataStore: EventsDataStoreProtocol { - func add(events: [EventInstance], forTokenContract contract: AlphaWallet.Address) { + + func getLastMatchingEventSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> Promise { + return .value(nil) } - func deleteEvents(forTokenContract contract: AlphaWallet.Address) { + func add(events: [EventInstanceValue], forTokenContract contract: AlphaWallet.Address) -> Promise { + return .init() } - func getMatchingEvents(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, filterName: String, filterValue: String) -> [EventInstance] { - .init() + func deleteEvents(forTokenContract contract: AlphaWallet.Address) { } - func getMatchingEventsSortedByBlockNumber(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String) -> [EventInstance] { + func getMatchingEvents(forContract contract: AlphaWallet.Address, tokenContract: AlphaWallet.Address, server: RPCServer, eventName: String, filterName: String, filterValue: String) -> [EventInstance] { .init() }