blockchainethereumblockchain-walleterc20erc721walletxdaidappdecentralizederc1155erc875iosswifttokens
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
590 lines
31 KiB
590 lines
31 KiB
//
|
|
// ActivitiesService.swift
|
|
// AlphaWallet
|
|
//
|
|
// Created by Vladyslav Shepitko on 17.05.2021.
|
|
//
|
|
|
|
import UIKit
|
|
import CoreFoundation
|
|
|
|
protocol ActivitiesServiceType: class {
|
|
var subscribableViewModel: Subscribable<ActivitiesViewModel> { get }
|
|
var subscribableUpdatedActivity: Subscribable<Activity> { get }
|
|
|
|
func stop()
|
|
func reinject(activity: Activity)
|
|
func copy(activitiesFilterStrategy: ActivitiesFilterStrategy, transactionsFilterStrategy: TransactionsFilterStrategy) -> ActivitiesServiceType
|
|
}
|
|
|
|
enum ActivitiesFilterStrategy {
|
|
case none
|
|
case nativeCryptocurrency(primaryKey: String)
|
|
case erc20(contract: AlphaWallet.Address)
|
|
|
|
func isRecentTransaction(transaction: TransactionInstance) -> Bool {
|
|
switch self {
|
|
case .nativeCryptocurrency:
|
|
return ActivitiesFilterStrategy.filterTransactionsForNativeCryptocurrency(transaction: transaction)
|
|
case .erc20(let contract):
|
|
return ActivitiesFilterStrategy.filterTransactionsForERC20Token(transaction: transaction, contract: contract)
|
|
case .none:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private static func filterTransactionsForNativeCryptocurrency(transaction: TransactionInstance) -> Bool {
|
|
(transaction.state == .completed || transaction.state == .pending) && (transaction.operation == nil) && (transaction.value != "" && transaction.value != "0")
|
|
}
|
|
|
|
private static func filterTransactionsForERC20Token(transaction: TransactionInstance, contract: AlphaWallet.Address) -> Bool {
|
|
(transaction.state == .completed || transaction.state == .pending) && transaction.localizedOperations.contains(where: { op in
|
|
(op.operationType == .erc20TokenTransfer || op.operationType == .erc20TokenApprove) && (op.contract.flatMap({ contract.sameContract(as: $0) }) ?? false)
|
|
})
|
|
}
|
|
}
|
|
|
|
class ParkBenchTimer {
|
|
|
|
var startTime: CFAbsoluteTime
|
|
|
|
init() {
|
|
startTime = CFAbsoluteTimeGetCurrent()
|
|
}
|
|
|
|
func stop() -> CFAbsoluteTime {
|
|
let endTime = CFAbsoluteTimeGetCurrent()
|
|
|
|
return duration(endTime: endTime)
|
|
}
|
|
|
|
func duration(endTime: CFAbsoluteTime = CFAbsoluteTimeGetCurrent()) -> CFAbsoluteTime {
|
|
return endTime - startTime
|
|
}
|
|
}
|
|
|
|
extension TransactionType {
|
|
var activitiesFilterStrategy: ActivitiesFilterStrategy {
|
|
switch self {
|
|
case .nativeCryptocurrency(let tokenObject, _, _):
|
|
return .nativeCryptocurrency(primaryKey: tokenObject.primaryKey)
|
|
case .ERC20Token(let tokenObject, _, _):
|
|
return .erc20(contract: tokenObject.contractAddress)
|
|
case .ERC875Token, .ERC875TokenOrder, .ERC721Token, .ERC721ForTicketToken, .dapp, .claimPaidErc875MagicLink, .tokenScript:
|
|
return .none
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum ActivityOrTransactionInstance {
|
|
case activity(Activity)
|
|
case transaction(TransactionInstance)
|
|
|
|
var blockNumber: Int {
|
|
switch self {
|
|
case .activity(let activity):
|
|
return activity.blockNumber
|
|
case .transaction(let transaction):
|
|
return transaction.blockNumber
|
|
}
|
|
}
|
|
|
|
var transaction: TransactionInstance? {
|
|
switch self {
|
|
case .activity:
|
|
return nil
|
|
case .transaction(let transaction):
|
|
return transaction
|
|
}
|
|
}
|
|
var activity: Activity? {
|
|
switch self {
|
|
case .activity(let activity):
|
|
return activity
|
|
case .transaction:
|
|
return nil
|
|
}
|
|
}
|
|
}
|
|
|
|
class ActivitiesService: NSObject, ActivitiesServiceType {
|
|
private let config: Config
|
|
private let sessions: ServerDictionary<WalletSession>
|
|
private let tokensStorages: ServerDictionary<TokensDataStore>
|
|
private let assetDefinitionStore: AssetDefinitionStore
|
|
private let eventsActivityDataStore: EventsActivityDataStoreProtocol
|
|
private let eventsDataStore: EventsDataStoreProtocol
|
|
//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: [TransactionInstance] {
|
|
filteredTransactionsSubscription.value ?? []
|
|
}
|
|
|
|
private var tokensAndTokenHolders: [AlphaWallet.Address: (tokenObject: Activity.AssignedToken, tokenHolders: [TokenHolder])] = .init()
|
|
private var rateLimitedUpdater: RateLimiter?
|
|
private var rateLimitedViewControllerReloader: RateLimiter?
|
|
private var hasLoadedActivitiesTheFirstTime = false
|
|
|
|
let subscribableUpdatedActivity: Subscribable<Activity> = .init(nil)
|
|
let subscribableViewModel: Subscribable<ActivitiesViewModel> = .init(.init(activities: []))
|
|
|
|
private var tokensInDatabase: [TokenObject] {
|
|
tokensStorages.values.flatMap { $0.enabledObject }
|
|
}
|
|
|
|
private var wallet: Wallet {
|
|
sessions.anyValue.account
|
|
}
|
|
|
|
private let queue: DispatchQueue
|
|
|
|
private let activitiesFilterStrategy: ActivitiesFilterStrategy
|
|
private var filteredTransactionsSubscriptionKey: Subscribable<[TransactionInstance]>.SubscribableKey!
|
|
private var recentEventsSubscriptionKey: Subscribable<[EventActivity]>.SubscribableKey!
|
|
private let transactionCollection: TransactionCollection
|
|
private lazy var recentEventsSubscribable = eventsActivityDataStore.recentEventsSubscribable
|
|
private lazy var filteredTransactionsSubscription = transactionCollection.subscribableFor(filter: transactionsFilterStrategy)
|
|
private let transactionsFilterStrategy: TransactionsFilterStrategy
|
|
|
|
init(
|
|
config: Config,
|
|
sessions: ServerDictionary<WalletSession>,
|
|
tokensStorages: ServerDictionary<TokensDataStore>,
|
|
assetDefinitionStore: AssetDefinitionStore,
|
|
eventsActivityDataStore: EventsActivityDataStoreProtocol,
|
|
eventsDataStore: EventsDataStoreProtocol,
|
|
transactionCollection: TransactionCollection,
|
|
activitiesFilterStrategy: ActivitiesFilterStrategy = .none,
|
|
transactionsFilterStrategy: TransactionsFilterStrategy = .all,
|
|
queue: DispatchQueue
|
|
) {
|
|
self.queue = queue
|
|
self.config = config
|
|
self.sessions = sessions
|
|
self.tokensStorages = tokensStorages
|
|
self.assetDefinitionStore = assetDefinitionStore
|
|
self.eventsDataStore = eventsDataStore
|
|
self.eventsActivityDataStore = eventsActivityDataStore
|
|
self.activitiesFilterStrategy = activitiesFilterStrategy
|
|
self.transactionCollection = transactionCollection
|
|
self.transactionsFilterStrategy = transactionsFilterStrategy
|
|
super.init()
|
|
|
|
filteredTransactionsSubscriptionKey = filteredTransactionsSubscription.subscribe { [weak self] _ in
|
|
self?.reloadImpl(reloadImmediately: true)
|
|
}
|
|
|
|
recentEventsSubscriptionKey = recentEventsSubscribable.subscribe { [weak self] _ in
|
|
self?.reloadImpl(reloadImmediately: true)
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
filteredTransactionsSubscription.unsubscribe(filteredTransactionsSubscriptionKey)
|
|
recentEventsSubscribable.unsubscribe(recentEventsSubscriptionKey)
|
|
|
|
eventsActivityDataStore.removeSubscription(subscription: recentEventsSubscribable)
|
|
transactionCollection.removeSubscription(subscription: filteredTransactionsSubscription)
|
|
}
|
|
|
|
func copy(activitiesFilterStrategy: ActivitiesFilterStrategy, transactionsFilterStrategy: TransactionsFilterStrategy) -> ActivitiesServiceType {
|
|
return ActivitiesService(config: config, sessions: sessions, tokensStorages: tokensStorages, assetDefinitionStore: assetDefinitionStore, eventsActivityDataStore: eventsActivityDataStore, eventsDataStore: eventsDataStore, transactionCollection: transactionCollection, activitiesFilterStrategy: activitiesFilterStrategy, transactionsFilterStrategy: transactionsFilterStrategy, queue: queue)
|
|
}
|
|
|
|
func stop() {
|
|
//TODO seems not good to stop here because others call stop too
|
|
for each in sessions.values {
|
|
each.stop()
|
|
}
|
|
}
|
|
|
|
private func reloadImpl(reloadImmediately: Bool) {
|
|
let contractServerXmlHandlers: [(contract: AlphaWallet.Address, server: RPCServer, xmlHandler: XMLHandler)] = tokensInDatabase.compactMap { each in
|
|
let eachContract = each.contractAddress
|
|
let eachServer = each.server
|
|
let xmlHandler = XMLHandler(token: each, assetDefinitionStore: assetDefinitionStore)
|
|
guard xmlHandler.hasAssetDefinition else { return nil }
|
|
guard xmlHandler.server?.matches(server: eachServer) ?? false else { return nil }
|
|
|
|
return (contract: eachContract, server: eachServer, xmlHandler: xmlHandler)
|
|
}
|
|
let contractsAndCardsOptional: [[(tokenContract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String)]] = contractServerXmlHandlers.compactMap { eachContract, _, xmlHandler in
|
|
var contractAndCard: [(tokenContract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String)] = .init()
|
|
for card in xmlHandler.activityCards {
|
|
let (filterName, filterValue) = card.eventOrigin.eventFilter
|
|
let interpolatedFilter: String
|
|
if let implicitAttribute = EventSourceCoordinator.functional.convertToImplicitAttribute(string: filterValue) {
|
|
switch implicitAttribute {
|
|
case .tokenId:
|
|
continue
|
|
case .ownerAddress:
|
|
interpolatedFilter = "\(filterName)=\(wallet.address.eip55String)"
|
|
case .label, .contractAddress, .symbol:
|
|
//TODO support more?
|
|
continue
|
|
}
|
|
} else {
|
|
//TODO support things like "$prefix-{tokenId}"
|
|
continue
|
|
}
|
|
|
|
guard let server = xmlHandler.server else { continue }
|
|
switch server {
|
|
case .any:
|
|
for each in config.enabledServers {
|
|
contractAndCard.append((tokenContract: eachContract, server: each, card: card, interpolatedFilter: interpolatedFilter))
|
|
}
|
|
case .server(let server):
|
|
contractAndCard.append((tokenContract: eachContract, server: server, card: card, interpolatedFilter: interpolatedFilter))
|
|
}
|
|
}
|
|
return contractAndCard
|
|
}
|
|
let contractsAndCards = contractsAndCardsOptional.flatMap { $0 }
|
|
fetchAndRefreshActivities(contractsAndCards: contractsAndCards, reloadImmediately: reloadImmediately)
|
|
}
|
|
|
|
private func fetchAndRefreshActivities(contractsAndCards: [(tokenContract: AlphaWallet.Address, server: RPCServer, card: TokenScriptCard, interpolatedFilter: String)], reloadImmediately: Bool) {
|
|
let recentEvents = eventsActivityDataStore.getRecentEvents()
|
|
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)
|
|
}
|
|
activities = activitiesAndTokens.map { $0.0 }
|
|
activities.sort { $0.blockNumber > $1.blockNumber }
|
|
updateActivitiesIndexLookup()
|
|
|
|
reloadViewController(reloadImmediately: reloadImmediately)
|
|
|
|
for (activity, tokenObject, tokenHolder) in activitiesAndTokens {
|
|
refreshActivity(tokenObject: tokenObject, tokenHolder: tokenHolder, activity: activity)
|
|
}
|
|
}
|
|
//Cache tokens lookup for performance
|
|
private static var tokensCache: ThreadSafeDictionary<AlphaWallet.Address, Activity.AssignedToken> = .init()
|
|
|
|
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
|
|
}
|
|
|
|
let activitiesForThisCard: [(activity: Activity, tokenObject: Activity.AssignedToken, tokenHolder: TokenHolder)] = events.compactMap { eachEvent in
|
|
let token: Activity.AssignedToken
|
|
if let t = Self.tokensCache[contract] {
|
|
token = t
|
|
} else {
|
|
guard let tokensDatastore = tokensStorages[safe: server] else { return nil }
|
|
guard let tt = tokensDatastore.tokenThreadSafe(forContract: contract).flatMap({ Activity.AssignedToken(tokenObject: $0) }) else { return nil }
|
|
Self.tokensCache[contract] = tt
|
|
token = tt
|
|
}
|
|
|
|
let implicitAttributes = generateImplicitAttributesForToken(forContract: contract, server: server, symbol: token.symbol)
|
|
let tokenAttributes = implicitAttributes
|
|
var cardAttributes = Self.functional.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: Activity.AssignedToken
|
|
let tokenHolders: [TokenHolder]
|
|
if let (o, h) = tokensAndTokenHolders[contract] {
|
|
tokenObject = o
|
|
tokenHolders = h
|
|
} else {
|
|
tokenObject = token
|
|
if tokenObject.contractAddress.sameContract(as: Constants.nativeCryptoAddressInDatabase) {
|
|
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 {
|
|
guard let tokensDatastore = tokensStorages[safe: server] else { return nil }
|
|
guard let t = tokensDatastore.tokenThreadSafe(forContract: tokenObject.contractAddress) else { return nil }
|
|
|
|
tokenHolders = TokenAdaptor(token: t, assetDefinitionStore: assetDefinitionStore, eventsDataStore: eventsDataStore).getTokenHolders(forWallet: wallet)
|
|
}
|
|
tokensAndTokenHolders[contract] = (tokenObject: tokenObject, tokenHolders: tokenHolders)
|
|
}
|
|
//NOTE: using `tokenHolders[0]` i received crash with out of range exception
|
|
guard let tokenHolder = tokenHolders.first else { return nil }
|
|
let activity = Activity(id: Int.random(in: 0..<Int.max), rowType: .standalone, tokenObject: tokenObject, server: eachEvent.server, name: card.name, eventName: eachEvent.eventName, blockNumber: eachEvent.blockNumber, transactionId: eachEvent.transactionId, transactionIndex: eachEvent.transactionIndex, logIndex: eachEvent.logIndex, date: eachEvent.date, values: (token: tokenAttributes, card: cardAttributes), view: card.view, itemView: card.itemView, isBaseCard: card.isBase, state: .completed)
|
|
|
|
return (activity: activity, tokenObject: tokenObject, tokenHolder: tokenHolder)
|
|
}
|
|
|
|
//TODO fix for activities: special fix to filter out the event we don't want - need to doc this and have to handle with TokenScript design
|
|
let filteredActivitiesForThisCard = activitiesForThisCard.filter {
|
|
if $0.activity.name == "aETHMinted" && $0.activity.tokenObject.contractAddress.sameContract(as: Constants.nativeCryptoAddressInDatabase) && $0.activity.values.card["amount"]?.uintValue == 0 {
|
|
return false
|
|
} else {
|
|
return true
|
|
}
|
|
}
|
|
|
|
switch activitiesFilterStrategy {
|
|
case .none:
|
|
return filteredActivitiesForThisCard
|
|
case .erc20(let contract):
|
|
return filteredActivitiesForThisCard.filter { mapped -> Bool in
|
|
return mapped.tokenObject.contractAddress.sameContract(as: contract)
|
|
}
|
|
case .nativeCryptocurrency(let primaryKey):
|
|
return filteredActivitiesForThisCard.filter { mapped -> Bool in
|
|
return mapped.tokenObject.primaryKey == primaryKey
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reloadViewController(reloadImmediately: Bool) {
|
|
if reloadImmediately {
|
|
reloadViewControllerImpl()
|
|
} else {
|
|
//We want to show the activities tab immediately the first time activities are available, otherwise when the app launch and user goes to the tab immediately and wait for a few seconds, they'll see some of the transactions transforming into activities. Very jarring
|
|
if hasLoadedActivitiesTheFirstTime {
|
|
if rateLimitedViewControllerReloader == nil {
|
|
rateLimitedViewControllerReloader = RateLimiter(name: "Reload activity/transactions in Activity tab", limit: 5, autoRun: true) { [weak self] in
|
|
self?.reloadViewControllerImpl()
|
|
}
|
|
} else {
|
|
rateLimitedViewControllerReloader?.run()
|
|
}
|
|
} else {
|
|
reloadViewControllerImpl()
|
|
}
|
|
}
|
|
}
|
|
|
|
func reinject(activity: Activity) {
|
|
queue.async { [weak self] in
|
|
guard let strongSelf = self else { return }
|
|
guard let (token, tokenHolder) = strongSelf.tokensAndTokenHolders[activity.tokenObject.contractAddress] else { return }
|
|
|
|
strongSelf.refreshActivity(tokenObject: token, tokenHolder: tokenHolder[0], activity: activity, isFirstUpdate: true)
|
|
}
|
|
}
|
|
|
|
private func reloadViewControllerImpl() {
|
|
if !activities.isEmpty {
|
|
hasLoadedActivitiesTheFirstTime = true
|
|
}
|
|
let transactions: [TransactionInstance]
|
|
if activities.count == EventsActivityDataStore.numberOfActivitiesToUse, let blockNumberOfOldestActivity = activities.last?.blockNumber {
|
|
transactions = self.transactions.filter { $0.blockNumber >= blockNumberOfOldestActivity }
|
|
} else {
|
|
transactions = self.transactions
|
|
}
|
|
|
|
let items = combine(activities: activities, withTransactions: transactions)
|
|
let activities = ActivitiesViewModel.sorted(activities: items)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.subscribableViewModel.value = .init(activities: activities)
|
|
}
|
|
}
|
|
|
|
//Combining includes filtering around activities (from events) for ERC20 send/receive transactions which are already covered by transactions
|
|
private func combine(activities: [Activity], withTransactions transactionInstances: [TransactionInstance]) -> [ActivityRowModel] {
|
|
let all: [ActivityOrTransactionInstance] = activities.map { .activity($0) } + transactionInstances.map { .transaction($0) }
|
|
let sortedAll: [ActivityOrTransactionInstance] = all.sorted { $0.blockNumber < $1.blockNumber }
|
|
var results: [ActivityRowModel] = .init()
|
|
let counters = Dictionary(grouping: sortedAll, by: \.blockNumber)
|
|
for (blockNumber, elements) in counters {
|
|
let rows = generateRowModels(fromActivityOrTransactions: elements, withBlockNumber: blockNumber)
|
|
results.append(contentsOf: rows)
|
|
}
|
|
|
|
return results
|
|
}
|
|
|
|
private func generateRowModels(fromActivityOrTransactions activityOrTransactions: [ActivityOrTransactionInstance], withBlockNumber blockNumber: Int) -> [ActivityRowModel] {
|
|
if activityOrTransactions.isEmpty {
|
|
//Shouldn't be possible
|
|
return .init()
|
|
} else if activityOrTransactions.count > 1 {
|
|
let activities: [Activity] = activityOrTransactions.compactMap(\.activity)
|
|
//TODO will we ever have more than 1 transaction object (not activity/event) in the database for the same block number? Maybe if we get 1 from normal Etherscan endpoint and another from Etherscan ERC20 history endpoint?
|
|
if let transaction: TransactionInstance = activityOrTransactions.compactMap(\.transaction).first {
|
|
var results: [ActivityRowModel] = .init()
|
|
let activities: [Activity] = activities.filter { activity in
|
|
let operations = transaction.localizedOperations
|
|
return operations.allSatisfy { activity != $0 }
|
|
}
|
|
let activity = ActivitiesViewModel.functional.createPseudoActivity(fromTransactionRow: .standalone(transaction), tokensStorages: tokensStorages, wallet: wallet.address)
|
|
if transaction.localizedOperations.isEmpty && activities.isEmpty {
|
|
results.append(.standaloneTransaction(transaction: transaction, activity: activity))
|
|
} else if transaction.localizedOperations.count == 1, transaction.value == "0", activities.isEmpty {
|
|
results.append(.standaloneTransaction(transaction: transaction, activity: activity))
|
|
} else if transaction.localizedOperations.isEmpty && activities.count == 1 {
|
|
results.append(.parentTransaction(transaction: transaction, isSwap: false, activities: activities))
|
|
results.append(contentsOf: activities.map { .childActivity(transaction: transaction, activity: $0) })
|
|
} else {
|
|
let isSwap = self.isSwap(activities: activities, operations: transaction.localizedOperations)
|
|
results.append(.parentTransaction(transaction: transaction, isSwap: isSwap, activities: activities))
|
|
|
|
results.append(contentsOf: transaction.localizedOperations.map {
|
|
let activity = ActivitiesViewModel.functional.createPseudoActivity(fromTransactionRow: .item(transaction: transaction, operation: $0), tokensStorages: tokensStorages, wallet: wallet.address)
|
|
return .childTransaction(transaction: transaction, operation: $0, activity: activity)
|
|
})
|
|
for each in activities {
|
|
results.append(.childActivity(transaction: transaction, activity: each))
|
|
}
|
|
}
|
|
return results
|
|
} else {
|
|
//TODO we should have a group here too to wrap activities with the same block number. No transaction, so more work
|
|
return activities.map { .standaloneActivity(activity: $0) }
|
|
}
|
|
} else {
|
|
switch activityOrTransactions.first {
|
|
case .activity(let activity):
|
|
return [.standaloneActivity(activity: activity)]
|
|
case .transaction(let transaction):
|
|
let activity = ActivitiesViewModel.functional.createPseudoActivity(fromTransactionRow: .standalone(transaction), tokensStorages: tokensStorages, wallet: wallet.address)
|
|
if transaction.localizedOperations.isEmpty {
|
|
return [.standaloneTransaction(transaction: transaction, activity: activity)]
|
|
} else if transaction.localizedOperations.count == 1 {
|
|
return [.standaloneTransaction(transaction: transaction, activity: activity)]
|
|
} else {
|
|
let isSwap = self.isSwap(activities: activities, operations: transaction.localizedOperations)
|
|
var results: [ActivityRowModel] = .init()
|
|
results.append(.parentTransaction(transaction: transaction, isSwap: isSwap, activities: .init()))
|
|
results.append(contentsOf: transaction.localizedOperations.map {
|
|
let activity = ActivitiesViewModel.functional.createPseudoActivity(fromTransactionRow: .item(transaction: transaction, operation: $0), tokensStorages: tokensStorages, wallet: wallet.address)
|
|
|
|
return .childTransaction(transaction: transaction, operation: $0, activity: activity)
|
|
})
|
|
return results
|
|
}
|
|
case .none:
|
|
return .init()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func isSwap(activities: [Activity], operations: [LocalizedOperationObjectInstance]) -> Bool {
|
|
//Might have other transactions like approved embedded, so we can't check for all send and receives.
|
|
let hasSend = activities.contains { $0.isSend } || operations.contains { $0.isSend(from: wallet.address) }
|
|
let hasReceive = activities.contains { $0.isReceive } || operations.contains { $0.isReceived(by: wallet.address) }
|
|
return hasSend && hasReceive
|
|
}
|
|
|
|
//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: 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)
|
|
}
|
|
|
|
//NOTE: Fix crush when element with index out of range
|
|
if let (index, oldActivity) = activitiesIndexLookup[activity.id], activities.indices.contains(index) {
|
|
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)
|
|
|
|
subscribableUpdatedActivity.value = updatedActivity
|
|
} else {
|
|
//no-op. We should be able to find it unless the list of activities has changed
|
|
}
|
|
}
|
|
|
|
private func generateImplicitAttributesForToken(forContract contract: AlphaWallet.Address, server: RPCServer, symbol: String) -> [String: AssetInternalValue] {
|
|
var results = [String: AssetInternalValue]()
|
|
for each in AssetImplicitAttributes.allCases {
|
|
//TODO ERC721s aren't fungible, but doesn't matter here
|
|
guard each.shouldInclude(forAddress: contract, isFungible: true) else { continue }
|
|
switch each {
|
|
case .ownerAddress:
|
|
results[each.javaScriptName] = .address(sessions[server].account.address)
|
|
case .tokenId:
|
|
//We aren't going to add `tokenId` as an implicit attribute even for ERC721s, because we don't know it
|
|
break
|
|
case .label:
|
|
break
|
|
case .symbol:
|
|
results[each.javaScriptName] = .string(symbol)
|
|
case .contractAddress:
|
|
results[each.javaScriptName] = .address(contract)
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
|
|
//We can't run this in `activities` didSet {} because this will then be run unnecessarily, when we refresh each activity (we only want this to update when we refresh the entire activity list)
|
|
private func updateActivitiesIndexLookup() {
|
|
var arrayIndex = -1
|
|
activitiesIndexLookup = Dictionary(uniqueKeysWithValues: activities.map {
|
|
arrayIndex += 1
|
|
return ($0.id, (arrayIndex, $0))
|
|
})
|
|
}
|
|
|
|
}
|
|
|
|
fileprivate func == (activity: Activity, operation: LocalizedOperationObjectInstance) -> Bool {
|
|
func isSameFrom() -> Bool {
|
|
guard let from = activity.values.card["from"]?.addressValue, from.sameContract(as: operation.from) else { return false }
|
|
return true
|
|
}
|
|
|
|
func isSameTo() -> Bool {
|
|
guard let to = activity.values.card["to"]?.addressValue, to.sameContract(as: operation.to) else { return false }
|
|
return true
|
|
}
|
|
|
|
func isSameAmount() -> Bool {
|
|
guard let amount = activity.values.card["amount"]?.uintValue, String(amount) == operation.value else { return false }
|
|
return true
|
|
}
|
|
|
|
guard let symbol = activity.values.token["symbol"]?.stringValue, symbol == operation.symbol else { return false }
|
|
let sameOperation: Bool = {
|
|
switch operation.operationType {
|
|
case .nativeCurrencyTokenTransfer:
|
|
//TODO not possible to hit this since we can't have an activity (event) for crypto send/received?
|
|
return activity.nativeViewType == .nativeCryptoSent || activity.nativeViewType == .nativeCryptoReceived
|
|
case .erc20TokenTransfer:
|
|
return (activity.nativeViewType == .erc20Sent || activity.nativeViewType == .erc20Received) && isSameAmount() && isSameFrom() && isSameTo()
|
|
case .erc20TokenApprove:
|
|
return activity.nativeViewType == .erc20OwnerApproved || activity.nativeViewType == .erc20ApprovalObtained || activity.nativeViewType == .erc721OwnerApproved || activity.nativeViewType == .erc721ApprovalObtained
|
|
case .erc721TokenTransfer:
|
|
return (activity.nativeViewType == .erc721Sent || activity.nativeViewType == .erc721Received) && isSameAmount() && isSameFrom() && isSameTo()
|
|
case .erc875TokenTransfer:
|
|
return false
|
|
case .unknown:
|
|
return false
|
|
}
|
|
}()
|
|
guard sameOperation else { return false }
|
|
return true
|
|
}
|
|
|
|
fileprivate func != (activity: Activity, operation: LocalizedOperationObjectInstance) -> Bool {
|
|
!(activity == operation)
|
|
}
|
|
|
|
extension ActivitiesService {
|
|
class functional {}
|
|
}
|
|
|
|
extension ActivitiesService.functional {
|
|
static func generateImplicitAttributesForCard(forContract contract: AlphaWallet.Address, server: RPCServer, event: EventActivity) -> [String: AssetInternalValue] {
|
|
var results = [String: AssetInternalValue]()
|
|
var timestamp: GeneralisedTime = .init()
|
|
timestamp.date = event.date
|
|
results["timestamp"] = .generalisedTime(timestamp)
|
|
return results
|
|
}
|
|
}
|
|
|