Extend OpenSea provider with persistent caching #4137

pull/4142/head
Krypto Pank 3 years ago
parent 87e0ae245d
commit 6a312927b7
  1. 16
      AlphaWallet.xcodeproj/project.pbxproj
  2. 19
      AlphaWallet/Core/Coordinators/WalletBalance/PrivateBalanceFetcher.swift
  3. 8
      AlphaWallet/Core/Coordinators/WalletBalance/WalletBalanceFetcher.swift
  4. 5
      AlphaWallet/Core/Coordinators/WalletBalance/WalletBalanceService.swift
  5. 13
      AlphaWallet/Core/NFT/NFTProvider.swift
  6. 280
      AlphaWallet/Core/OpenSea/OpenSea.swift
  7. 215
      AlphaWallet/Core/OpenSea/OpenSeaNetworkProvider.swift
  8. 13
      AlphaWallet/Core/OpenSea/Types/OpenSeaNonFungible.swift
  9. 3
      AlphaWallet/InCoordinator.swift
  10. 4
      AlphaWallet/Tokens/Types/AddressAndRPCServer.swift

@ -827,6 +827,8 @@
87584B4425EFAFEC0070063B /* BuyTokenService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87584B4325EFAFEC0070063B /* BuyTokenService.swift */; };
87584B4725EFB0950070063B /* Ramp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87584B4625EFB0950070063B /* Ramp.swift */; };
87584FA727E9F8A2006A7CD1 /* NFTBalanceViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87584FA627E9F8A2006A7CD1 /* NFTBalanceViewModel.swift */; };
87584FAA27EA2185006A7CD1 /* NFTProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87584FA927EA2185006A7CD1 /* NFTProvider.swift */; };
87584FAC27EB8139006A7CD1 /* OpenSeaNetworkProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 87584FAB27EB8139006A7CD1 /* OpenSeaNetworkProvider.swift */; };
8759C9E7279ADEFC00FE361F /* WalletAddressesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8759C9E6279ADEFC00FE361F /* WalletAddressesStore.swift */; };
8759C9E9279BD7B100FE361F /* DefaultsWalletAddressesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8759C9E8279BD7B100FE361F /* DefaultsWalletAddressesStore.swift */; };
8759C9EB279BD7C300FE361F /* JsonWalletAddressesStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8759C9EA279BD7C300FE361F /* JsonWalletAddressesStore.swift */; };
@ -1918,6 +1920,8 @@
87584B4325EFAFEC0070063B /* BuyTokenService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuyTokenService.swift; sourceTree = "<group>"; };
87584B4625EFB0950070063B /* Ramp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ramp.swift; sourceTree = "<group>"; };
87584FA627E9F8A2006A7CD1 /* NFTBalanceViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFTBalanceViewModel.swift; sourceTree = "<group>"; };
87584FA927EA2185006A7CD1 /* NFTProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NFTProvider.swift; sourceTree = "<group>"; };
87584FAB27EB8139006A7CD1 /* OpenSeaNetworkProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenSeaNetworkProvider.swift; sourceTree = "<group>"; };
8759C9E6279ADEFC00FE361F /* WalletAddressesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WalletAddressesStore.swift; sourceTree = "<group>"; };
8759C9E8279BD7B100FE361F /* DefaultsWalletAddressesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DefaultsWalletAddressesStore.swift; sourceTree = "<group>"; };
8759C9EA279BD7C300FE361F /* JsonWalletAddressesStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JsonWalletAddressesStore.swift; sourceTree = "<group>"; };
@ -3342,6 +3346,7 @@
29FC9BC31F830880000209CD /* Core */ = {
isa = PBXGroup;
children = (
87584FA827EA217C006A7CD1 /* NFT */,
2932045D1F8EEE760095B7C1 /* TokenBalanceService.swift */,
870CC47727C6560800A19380 /* TokensAutodetector */,
874D2F7C27A2B14C005459DA /* UnstoppableDomains */,
@ -4810,6 +4815,14 @@
path = Ramp;
sourceTree = "<group>";
};
87584FA827EA217C006A7CD1 /* NFT */ = {
isa = PBXGroup;
children = (
87584FA927EA2185006A7CD1 /* NFTProvider.swift */,
);
path = NFT;
sourceTree = "<group>";
};
87741F1B2770C3AD007F4604 /* WhereAreMyTokens */ = {
isa = PBXGroup;
children = (
@ -5035,6 +5048,7 @@
87C2008F27A8806300338D26 /* Helpers */,
87C2008A27A87FBE00338D26 /* Types */,
5E7C7C0CFD047ED7C488FB45 /* OpenSea.swift */,
87584FAB27EB8139006A7CD1 /* OpenSeaNetworkProvider.swift */,
);
path = OpenSea;
sourceTree = "<group>";
@ -5901,6 +5915,7 @@
AA26C62420412A4100318B9B /* Double.swift in Sources */,
298542FB1FBEA03300CB5081 /* SendInputErrors.swift in Sources */,
02B01A6327B3CD6F00379A00 /* ButtonsBarStyle.swift in Sources */,
87584FAA27EA2185006A7CD1 /* NFTProvider.swift in Sources */,
29EB102A1F6CBD23000907A4 /* UIAlertController.swift in Sources */,
296AF9A51F736BA20058AF78 /* Config.swift in Sources */,
7721A6C8202EF81B004DB16C /* CustomRPC.swift in Sources */,
@ -6572,6 +6587,7 @@
5E7C7068825D19D22329AC7E /* SendTransactionErrorViewController.swift in Sources */,
5E7C786DB5B71302FF66CBAD /* SendTransactionErrorViewModel.swift in Sources */,
872D9567271D66C200870971 /* GasPriceEstimates.swift in Sources */,
87584FAC27EB8139006A7CD1 /* OpenSeaNetworkProvider.swift in Sources */,
5E7C71FA0D6A918598005EF3 /* SendTransactionError.swift in Sources */,
5E7C715B5921162B65A9A706 /* PromptViewController.swift in Sources */,
02D8BF8D277D572600EEE8E9 /* SaveCustomRpcManualEntryView.swift in Sources */,

@ -36,7 +36,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
weak var erc721TokenIdsFetcher: Erc721TokenIdsFetcher?
private let account: Wallet
let openSea: OpenSea
private let nftProvider: NFTProvider
private let queue: DispatchQueue
private let server: RPCServer
private lazy var etherToken = Activity.AssignedToken(tokenObject: MultipleChainsTokensDataStore.functional.etherToken(forServer: server))
@ -47,14 +47,12 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
private let enjin: EnjinProvider
private var cachedErc1155TokenIdsFetchers: [AddressAndRPCServer: Erc1155TokenIdsFetcher] = [:]
private var cancelable = Set<AnyCancellable>()
private let keystore: Keystore
init(account: Wallet, keystore: Keystore, tokensDataStore: TokensDataStore, server: RPCServer, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue) {
self.keystore = keystore
init(account: Wallet, nftProvider: NFTProvider, tokensDataStore: TokensDataStore, server: RPCServer, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue) {
self.nftProvider = nftProvider
self.account = account
self.server = server
self.queue = queue
self.openSea = OpenSea.createInstance(with: AddressAndRPCServer(address: account.address, server: server), keystore: keystore)
self.enjin = EnjinProvider.createInstance(with: AddressAndRPCServer(address: account.address, server: server))
self.tokensDataStore = tokensDataStore
self.assetDefinitionStore = assetDefinitionStore
@ -106,15 +104,7 @@ class PrivateBalanceFetcher: PrivateBalanceFetcherType {
}
private func getTokensFromOpenSea() -> Promise<OpenSeaNonFungiblesToAddress> {
// TODO when we no longer create multiple instances of TokensDataStore, we don't have to use singleton for OpenSea class. This was to avoid fetching multiple times from OpenSea concurrently
// NOTE: We need to reduce amount of concurrent calls to Open Sea, because of call trolling of OpenSea, that is why we make calls only for current wallet
guard keystore.currentWallet.address == account.address else {
return .value([:])
}
return openSea.makeFetchPromise()
.recover { _ -> Promise<OpenSeaNonFungiblesToAddress> in
return .value([:])
}
return nftProvider.nonFungible(wallet: account, server: server)
}
func refreshBalance(updatePolicy: RefreshBalancePolicy, force: Bool = false) -> Promise<Void> {
@ -511,6 +501,7 @@ fileprivate extension PrivateBalanceFetcher.functional {
Erc1155BalanceFetcher(address: account.address, server: server)
.fetch(contract: contract, tokenIds: Set(tokenIds))
.map { (contract: contract, balances: $0) }
.recover { _ in return .value((contract: contract, balances: [:]))}
}
return firstly {
when(fulfilled: promises)

@ -43,7 +43,7 @@ class WalletBalanceFetcher: NSObject, WalletBalanceFetcherType {
private lazy var tokensDataStore: TokensDataStore = {
return MultipleChainsTokensDataStore(realm: realm, account: wallet, servers: config.enabledServers)
}()
private let keystore: Keystore
private let nftProvider: NFTProvider
private lazy var transactionsStorage = TransactionDataStore(realm: realm, delegate: self)
private lazy var realm = Wallet.functional.realm(forAccount: wallet)
private var cancelable = Set<AnyCancellable>()
@ -64,9 +64,9 @@ class WalletBalanceFetcher: NSObject, WalletBalanceFetcherType {
.eraseToAnyPublisher()
}
required init(wallet: Wallet, keystore: Keystore, config: Config, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue, coinTickersFetcher: CoinTickersFetcherType) {
required init(wallet: Wallet, nftProvider: NFTProvider, config: Config, assetDefinitionStore: AssetDefinitionStore, queue: DispatchQueue, coinTickersFetcher: CoinTickersFetcherType) {
self.wallet = wallet
self.keystore = keystore
self.nftProvider = nftProvider
self.assetDefinitionStore = assetDefinitionStore
self.queue = queue
self.coinTickersFetcher = coinTickersFetcher
@ -96,7 +96,7 @@ class WalletBalanceFetcher: NSObject, WalletBalanceFetcherType {
}
private func createBalanceFetcher(wallet: Wallet, server: RPCServer) -> PrivateBalanceFetcher {
let balanceFetcher = PrivateBalanceFetcher(account: wallet, keystore: keystore, tokensDataStore: tokensDataStore, server: server, assetDefinitionStore: assetDefinitionStore, queue: queue)
let balanceFetcher = PrivateBalanceFetcher(account: wallet, nftProvider: nftProvider, tokensDataStore: tokensDataStore, server: server, assetDefinitionStore: assetDefinitionStore, queue: queue)
balanceFetcher.erc721TokenIdsFetcher = transactionsStorage
balanceFetcher.delegate = self

@ -47,7 +47,8 @@ class MultiWalletBalanceService: NSObject, WalletBalanceService {
private let queue: DispatchQueue = DispatchQueue(label: "com.MultiWalletBalanceService.updateQueue")
private let walletAddressesStore: WalletAddressesStore
private var cancelable = Set<AnyCancellable>()
private let nftProvider: NFTProvider = OpenSea()
var walletsSummary: AnyPublisher<WalletSummary, Never> {
return walletsSummarySubject
.eraseToAnyPublisher()
@ -169,7 +170,7 @@ class MultiWalletBalanceService: NSObject, WalletBalanceService {
}
private func createWalletBalanceFetcher(wallet: Wallet) -> WalletBalanceFetcherType {
let fetcher = WalletBalanceFetcher(wallet: wallet, keystore: keystore, config: config, assetDefinitionStore: assetDefinitionStore, queue: queue, coinTickersFetcher: coinTickersFetcher)
let fetcher = WalletBalanceFetcher(wallet: wallet, nftProvider: nftProvider, config: config, assetDefinitionStore: assetDefinitionStore, queue: queue, coinTickersFetcher: coinTickersFetcher)
fetcher.delegate = self
return fetcher

@ -0,0 +1,13 @@
//
// NFTService.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 22.03.2022.
//
import Foundation
import PromiseKit
protocol NFTProvider: AnyObject {
func nonFungible(wallet: Wallet, server: RPCServer) -> Promise<OpenSeaNonFungiblesToAddress>
}

@ -5,44 +5,20 @@ import Alamofire
import BigInt
import PromiseKit
import Result
import SwiftyJSON
import SwiftyJSON
typealias OpenSeaNonFungiblesToAddress = [AlphaWallet.Address: [OpenSeaNonFungible]]
class OpenSea {
private static var statsCache: [String: OpenSea.Stats] = [:]
//Assuming 1 token (token ID, rather than a token) is 4kb, 1500 HyperDragons is 6MB. So we rate limit requests
private static let numberOfTokenIdsBeforeRateLimitingRequests = 25
private static let minimumSecondsBetweenRequests = TimeInterval(60)
private static var instances = [AddressAndRPCServer: WeakRef<OpenSea>]()
//NOTE: using AddressAndRPCServer fixes issue with incorrect tokens returned from makeFetchPromise
// the problem was that cached OpenSea returned tokens from multiple wallets
private let key: AddressAndRPCServer
final class OpenSea: NFTProvider {
private let storage: SubscribableFileStorage<[AddressAndRPCServer: OpenSeaNonFungiblesToAddress]>
private var fetchePromises: [AddressAndRPCServer: Promise<OpenSeaNonFungiblesToAddress>] = [:]
private let queue: DispatchQueue
private var networkProvider: OpenSeaNetworkProvider
private var recentWalletsWithManyTokens: [AlphaWallet.Address: (Date, Promise<OpenSeaNonFungiblesToAddress>)] = [:]
private var fetch = OpenSea.makeEmptyFulfilledPromise()
private let queue = DispatchQueue.global(qos: .userInitiated)
private let keystore: Keystore
private init(key: AddressAndRPCServer, keystore: Keystore) {
self.key = key
self.keystore = keystore
}
static func createInstance(with key: AddressAndRPCServer, keystore: Keystore) -> OpenSea {
if let instance = instances[key]?.object {
return instance
} else {
let instance = OpenSea(key: key, keystore: keystore)
instances[key] = WeakRef(object: instance)
return instance
}
}
private static func makeEmptyFulfilledPromise() -> Promise<OpenSeaNonFungiblesToAddress> {
return Promise {
$0.fulfill([:])
}
init() {
self.queue = DispatchQueue(label: "com.OpenSea.UpdateQueue")
self.networkProvider = OpenSeaNetworkProviderX(queue: queue)
self.storage = .init(fileName: "OpenSea", defaultValue: [:])
}
static func isServerSupported(_ server: RPCServer) -> Bool {
@ -54,224 +30,66 @@ class OpenSea {
}
}
static func resetInstances() {
for each in instances.values {
each.object?.reset()
}
}
///Call this after switching wallets, otherwise when the current promise is fulfilled, the switched to wallet will think the API results are for them
private func reset() {
fetch = OpenSea.makeEmptyFulfilledPromise()
}
func nonFungible(wallet: Wallet, server: RPCServer) -> Promise<OpenSeaNonFungiblesToAddress> {
let key: AddressAndRPCServer = .init(address: wallet.address, server: server)
///Uses a promise to make sure we don't fetch from OpenSea multiple times concurrently
func makeFetchPromise() -> Promise<OpenSeaNonFungiblesToAddress> {
let owner = key.address
guard OpenSea.isServerSupported(key.server) else {
fetch = .value([:])
return fetch
}
trimCachedPromises()
if let cachedPromise = cachedPromise(forOwner: owner) {
return cachedPromise
}
let queue = queue
if fetch.isResolved {
let offset = 0
//NOTE: some of OpenSea collections have an empty `primary_asset_contracts` array, so we are not able to identifyto each asset connection relates. it solves with `slug` field for collection. We match assets `slug` with collections `slug` values for identification
func findCollection(address: AlphaWallet.Address, asset: OpenSeaNonFungible, collections: [OpenSea.CollectionKey: OpenSea.Collection]) -> OpenSea.Collection? {
return collections[.address(address)] ?? collections[.slug(asset.slug)]
}
//NOTE: Due to OpenSea's policy of sending requests, (we are not able to sent multiple requests, the request trottled, and 1 sec delay is needed)
//to send a new one. First we send fetch assets requests and then fetch collections requests
typealias OpenSeaAssetsAndCollections = ([AlphaWallet.Address: [OpenSeaNonFungible]], [OpenSea.CollectionKey: OpenSea.Collection])
fetch = firstly {
fetchAssetsPage(forOwner: owner, offset: offset)
}.then(on: queue, { assets -> Promise<OpenSeaAssetsAndCollections> in
return self.fetchCollectionsPage(forOwner: owner, offset: offset)
.map({ collections -> OpenSeaAssetsAndCollections in
return (assets, collections)
})
}).map(on: queue, { (assetsExcludingUefa, collections) -> [AlphaWallet.Address: [OpenSeaNonFungible]] in
var result: [AlphaWallet.Address: [OpenSeaNonFungible]] = [:]
for each in assetsExcludingUefa {
let updatedElements = each.value.map { openSeaNonFungible -> OpenSeaNonFungible in
var openSeaNonFungible = openSeaNonFungible
let collection = findCollection(address: each.key, asset: openSeaNonFungible, collections: collections)
openSeaNonFungible.collection = collection
return openSeaNonFungible
}
result[each.key] = updatedElements
}
//NOTE: Not sure if we still need this caching feature, as we retry each failured request
var tokenIdCount = 0
for (_, tokenIds) in assetsExcludingUefa {
tokenIdCount += tokenIds.count
}
self.cachePromise(withTokenIdCount: tokenIdCount, forOwner: owner)
return result
})
fetchePromises[key] = .value([:])
return fetchePromises[key]!
}
return fetch
}
private static func getBaseURLForOpensea(for server: RPCServer) -> String {
switch server {
case .main:
return Constants.openseaAPI
case .rinkeby:
return Constants.openseaRinkebyAPI
case .kovan, .ropsten, .poa, .sokol, .classic, .callisto, .xDai, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet, .optimistic, .optimisticKovan, .cronosTestnet, .arbitrum, .arbitrumRinkeby, .palm, .palmTestnet:
return Constants.openseaAPI
}
return makeFetchPromise(for: key)
}
static func fetchAssetImageUrl(for value: Eip155URL) -> Promise<URL> {
let baseURL = getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/asset/\(value.path)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
private func makeFetchPromise(for key: AddressAndRPCServer) -> Promise<OpenSeaNonFungiblesToAddress> {
if let promise = fetchePromises[key] {
if promise.isResolved {
let promise = makeFetchFromLocalAndRemotePromise(key: key)
fetchePromises[key] = promise
return OpenSea.performOpenSeaRequest(url: url, queue: .main).map { json -> URL in
let image: String = json["image_url"].string ?? json["image_preview_url"].string ?? json["image_thumbnail_url"].string ?? json["image_original_url"].string ?? ""
guard let url = URL(string: image) else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL)"))
return promise
} else {
return promise
}
return url
}
}
private static let sessionManagerWithDefaultHttpHeaders: SessionManager = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 30
return SessionManager(configuration: configuration)
}()
static func collectionStats(slug: String) -> Promise<Stats> {
let baseURL = OpenSea.getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/collection/\(slug)/stats") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return OpenSea.performOpenSeaRequest(url: url, queue: .main).map { json -> Stats in
return try Stats(json: json)
}
}
} else {
let promise = makeFetchFromLocalAndRemotePromise(key: key)
fetchePromises[key] = promise
private func fetchCollectionsPage(forOwner owner: AlphaWallet.Address, offset: Int, sum: [OpenSea.CollectionKey: OpenSea.Collection] = [:]) -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> {
let baseURL = OpenSea.getBaseURLForOpensea(for: key.server)
guard let url = URL(string: "\(baseURL)api/v1/collections?asset_owner=\(owner.eip55String)&limit=300&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
return promise
}
return OpenSea.performOpenSeaRequest(url: url, queue: queue)
.then({ [weak self] json -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let results = OpenSeaCollectionDecoder.decode(json: json, results: sum)
let fetchedCount = json.arrayValue.count
if fetchedCount > 0 {
return strongSelf.fetchCollectionsPage(forOwner: owner, offset: offset + fetchedCount, sum: results)
} else {
return .value(sum)
}
}).recover { _ -> Promise<[OpenSea.CollectionKey: OpenSea.Collection]> in
//NOTE: return some already fetched amount
return .value(sum)
}
}
private static func performOpenSeaRequest(url: URL, maximumRetryCount: Int = 3, delayMultiplayer: Int = 5, retryDelay: DispatchTimeInterval = .seconds(2), queue: DispatchQueue) -> Promise<JSON> {
struct OpenSeaRequestTrottled: Error {}
func privatePerformOpenSeaRequest(url: URL) -> Promise<(HTTPURLResponse, JSON)> {
return OpenSea.sessionManagerWithDefaultHttpHeaders
.request(url, method: .get, headers: ["X-API-KEY": Constants.Credentials.openseaKey])
.responseJSON(queue: queue, options: .allowFragments)
.map(on: queue, { response -> (HTTPURLResponse, JSON) in
guard let data = response.response.data, let json = try? JSON(data: data), let httpResponse = response.response.response else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(url)"))
private func makeFetchFromLocalAndRemotePromise(key: AddressAndRPCServer) -> Promise<OpenSeaNonFungiblesToAddress> {
return networkProvider
.fetchAssetsPromise(address: key.address, server: key.server)
.map { result in
if result.hasError {
let merged = (self.storage.value[key] ?? [:])
.merging(result.result) { Array(Set($0 + $1)) }
if merged.isEmpty {
//no-op
} else {
self.storage.value[key] = merged
}
return (httpResponse, json)
})
}
var delayUpperRangeValueFrom0To: Int = delayMultiplayer
return firstly {
attempt(maximumRetryCount: maximumRetryCount, delayBeforeRetry: retryDelay, delayUpperRangeValueFrom0To: delayUpperRangeValueFrom0To) {
privatePerformOpenSeaRequest(url: url).map { (httpResponse, json) -> (HTTPURLResponse, JSON) in
guard httpResponse.statusCode != 429 else {
delayUpperRangeValueFrom0To += delayMultiplayer
throw OpenSeaRequestTrottled()
}
return (httpResponse, json)
} else {
self.storage.value[key] = result.result
}
}.map { (_, json) -> JSON in
return json
}
}
}
private func fetchAssetsPage(forOwner owner: AlphaWallet.Address, offset: Int, assets: [AlphaWallet.Address: [OpenSeaNonFungible]] = [:]) -> Promise< [AlphaWallet.Address: [OpenSeaNonFungible]]> {
let baseURL = OpenSea.getBaseURLForOpensea(for: key.server)
//Careful to `order_by` with a valid value otherwise OpenSea will return 0 results
guard let url = URL(string: "\(baseURL)api/v1/assets/?owner=\(owner.eip55String)&order_by=pk&order_direction=asc&limit=50&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
let result = self.storage.value[key] ?? result.result
return OpenSea.performOpenSeaRequest(url: url, queue: queue)
.then({ [weak self] json -> Promise<[AlphaWallet.Address: [OpenSeaNonFungible]]> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let ss = result.map { "\($0.truncateMiddle.suffix(4)) - \($1.count)" }.joined(separator: ",")
print("XXX.OpenSea local: \(key.server), account: \(key.address.truncateMiddle.suffix(4)), values: [\(ss)]")
let results = OpenSeaAssetDecoder.decode(json: json, assets: assets)
let fetchedCount = json["assets"].count
if fetchedCount > 0 {
return strongSelf.fetchAssetsPage(forOwner: owner, offset: offset + fetchedCount, assets: results)
} else {
//Ignore UEFA from OpenSea, otherwise the token type would be saved wrongly as `.erc721` instead of `.erc721ForTickets`
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(assetsExcludingUefa)
}
}).recover { _ -> Promise< [AlphaWallet.Address: [OpenSeaNonFungible]]> in
//NOTE: return some already fetched amount
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(assetsExcludingUefa)
return result
}
}
private func cachePromise(withTokenIdCount tokenIdCount: Int, forOwner wallet: AlphaWallet.Address) {
guard tokenIdCount >= OpenSea.numberOfTokenIdsBeforeRateLimitingRequests else { return }
recentWalletsWithManyTokens[wallet] = (Date(), fetch)
static func fetchAssetImageUrl(for value: Eip155URL) -> Promise<URL> {
OpenSea().networkProvider.fetchAssetImageUrl(for: value)
}
private func cachedPromise(forOwner wallet: AlphaWallet.Address) -> Promise<OpenSeaNonFungiblesToAddress>? {
guard let (_, promise) = recentWalletsWithManyTokens[wallet] else { return nil }
return promise
static func collectionStats(slug: String) -> Promise<Stats> {
OpenSea().networkProvider.collectionStats(slug: slug)
}
private func trimCachedPromises() {
let cachedWallets = recentWalletsWithManyTokens.keys
let now = Date()
for each in cachedWallets {
guard let (date, _) = recentWalletsWithManyTokens[each] else { continue }
if now.timeIntervalSince(date) >= OpenSea.minimumSecondsBetweenRequests {
recentWalletsWithManyTokens.removeValue(forKey: each)
}
}
}
}

@ -0,0 +1,215 @@
//
// OpenSeaNetworkProvider.swift
// AlphaWallet
//
// Created by Vladyslav Shepitko on 23.03.2022.
//
import Alamofire
import BigInt
import PromiseKit
import Result
import SwiftyJSON
protocol OpenSeaNetworkProvider: AnyObject {
func collectionStats(slug: String) -> Promise<OpenSea.Stats>
func fetchAssetImageUrl(for value: Eip155URL) -> Promise<URL>
func fetchAssetsPromise(address owner: AlphaWallet.Address, server: RPCServer) -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>>
}
final class OpenSeaNetworkProviderX: OpenSeaNetworkProvider {
private let sessionManagerWithDefaultHttpHeaders: SessionManager = {
let configuration = URLSessionConfiguration.default
configuration.timeoutIntervalForRequest = 30
configuration.timeoutIntervalForResource = 30
return SessionManager(configuration: configuration)
}()
private let queue: DispatchQueue
init(queue: DispatchQueue) {
self.queue = queue
}
func fetchAssetsPromise(address owner: AlphaWallet.Address, server: RPCServer) -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>> {
let offset = 0
//NOTE: some of OpenSea collections have an empty `primary_asset_contracts` array, so we are not able to identifyto each asset connection relates. it solves with `slug` field for collection. We match assets `slug` with collections `slug` values for identification
func findCollection(address: AlphaWallet.Address, asset: OpenSeaNonFungible, collections: [OpenSea.CollectionKey: OpenSea.Collection]) -> OpenSea.Collection? {
return collections[.address(address)] ?? collections[.slug(asset.slug)]
}
//NOTE: Due to OpenSea's policy of sending requests, (we are not able to sent multiple requests, the request trottled, and 1 sec delay is needed)
//to send a new one. First we send fetch assets requests and then fetch collections requests
typealias OpenSeaAssetsAndCollections = (OpenSeaNonFungiblesToAddress, [OpenSea.CollectionKey: OpenSea.Collection])
let assetsPromise = fetchAssetsPage(forOwner: owner, server: server, offset: offset)
let collectionsPromise = fetchCollectionsPage(forOwner: owner, server: server, offset: offset)
return when(resolved: [assetsPromise.asVoid(), collectionsPromise.asVoid()])
.map(on: queue, { _ -> OpenSea.Response<OpenSeaNonFungiblesToAddress> in
let assetsExcludingUefa = assetsPromise.result?.optionalValue ?? .init(hasError: true, result: [:])
let collections = collectionsPromise.result?.optionalValue ?? .init(hasError: true, result: [:])
var result: [AlphaWallet.Address: [OpenSeaNonFungible]] = [:]
for each in assetsExcludingUefa.result {
let updatedElements = each.value.map { openSeaNonFungible -> OpenSeaNonFungible in
var openSeaNonFungible = openSeaNonFungible
let collection = findCollection(address: each.key, asset: openSeaNonFungible, collections: collections.result)
openSeaNonFungible.collection = collection
return openSeaNonFungible
}
result[each.key] = updatedElements
}
let hasError = assetsExcludingUefa.hasError || collections.hasError
return .init(hasError: hasError, result: result)
})
.recover({ _ -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>> in
return .value(.init(hasError: true, result: [:]))
})
}
private func getBaseURLForOpensea(for server: RPCServer) -> String {
switch server {
case .main:
return Constants.openseaAPI
case .rinkeby:
return Constants.openseaRinkebyAPI
case .kovan, .ropsten, .poa, .sokol, .classic, .callisto, .xDai, .goerli, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .custom, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet, .optimistic, .optimisticKovan, .cronosTestnet, .arbitrum, .arbitrumRinkeby, .palm, .palmTestnet:
return Constants.openseaAPI
}
}
func fetchAssetImageUrl(for value: Eip155URL) -> Promise<URL> {
let baseURL = getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/asset/\(value.path)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return performRequestWithRetry(url: url, queue: .main).map { json -> URL in
let image: String = json["image_url"].string ?? json["image_preview_url"].string ?? json["image_thumbnail_url"].string ?? json["image_original_url"].string ?? ""
guard let url = URL(string: image) else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL)"))
}
return url
}
}
func collectionStats(slug: String) -> Promise<OpenSea.Stats> {
let baseURL = getBaseURLForOpensea(for: .main)
guard let url = URL(string: "\(baseURL)api/v1/collection/\(slug)/stats") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return performRequestWithRetry(url: url, queue: .main).map { json -> OpenSea.Stats in
return try OpenSea.Stats(json: json)
}
}
private func fetchCollectionsPage(forOwner owner: AlphaWallet.Address, server: RPCServer, offset: Int, sum: [OpenSea.CollectionKey: OpenSea.Collection] = [:]) -> Promise<OpenSea.Response<[OpenSea.CollectionKey: OpenSea.Collection]>> {
let baseURL = getBaseURLForOpensea(for: server)
guard let url = URL(string: "\(baseURL)api/v1/collections?asset_owner=\(owner.eip55String)&limit=300&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return performRequestWithRetry(url: url, queue: queue)
.then({ [weak self] json -> Promise<OpenSea.Response<[OpenSea.CollectionKey: OpenSea.Collection]>> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let results = OpenSeaCollectionDecoder.decode(json: json, results: sum)
let fetchedCount = json.arrayValue.count
if fetchedCount > 0 {
return strongSelf.fetchCollectionsPage(forOwner: owner, server: server, offset: offset + fetchedCount, sum: results)
} else {
return .value(.init(hasError: false, result: sum))
}
})
.recover { _ -> Promise<OpenSea.Response<[OpenSea.CollectionKey: OpenSea.Collection]>> in
//NOTE: return some already fetched amount
return .value(.init(hasError: true, result: sum))
}
}
private func performRequestWithRetry(url: URL, maximumRetryCount: Int = 3, delayMultiplayer: Int = 5, retryDelay: DispatchTimeInterval = .seconds(2), queue: DispatchQueue) -> Promise<JSON> {
struct OpenSeaRequestTrottled: Error {}
func privatePerformRequest(url: URL) -> Promise<(HTTPURLResponse, JSON)> {
return sessionManagerWithDefaultHttpHeaders
.request(url, method: .get, headers: ["X-API-KEY": Constants.Credentials.openseaKey])
.responseJSON(queue: queue, options: .allowFragments)
.map(on: queue, { response -> (HTTPURLResponse, JSON) in
guard let data = response.response.data, let json = try? JSON(data: data), let httpResponse = response.response.response else {
throw AnyError(OpenSeaError(localizedDescription: "Error calling \(url)"))
}
return (httpResponse, json)
})
}
var delayUpperRangeValueFrom0To: Int = delayMultiplayer
return firstly {
attempt(maximumRetryCount: maximumRetryCount, delayBeforeRetry: retryDelay, delayUpperRangeValueFrom0To: delayUpperRangeValueFrom0To) {
privatePerformRequest(url: url).map { (httpResponse, json) -> JSON in
guard httpResponse.statusCode != 429 else {
delayUpperRangeValueFrom0To += delayMultiplayer
throw OpenSeaRequestTrottled()
}
return json
}
}
}
}
private func fetchAssetsPage(forOwner owner: AlphaWallet.Address, server: RPCServer, offset: Int, assets: OpenSeaNonFungiblesToAddress = [:]) -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>> {
let baseURL = getBaseURLForOpensea(for: server)
//Careful to `order_by` with a valid value otherwise OpenSea will return 0 results
guard let url = URL(string: "\(baseURL)api/v1/assets/?owner=\(owner.eip55String)&order_by=pk&order_direction=asc&limit=50&offset=\(offset)") else {
return .init(error: AnyError(OpenSeaError(localizedDescription: "Error calling \(baseURL) API \(Thread.isMainThread)")))
}
return performRequestWithRetry(url: url, queue: queue)
.then({ [weak self] json -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>> in
guard let strongSelf = self else { return .init(error: PMKError.cancelled) }
let results = OpenSeaAssetDecoder.decode(json: json, assets: assets)
let fetchedCount = json["assets"].count
if fetchedCount > 0 {
return strongSelf.fetchAssetsPage(forOwner: owner, server: server, offset: offset + fetchedCount, assets: results)
} else {
//Ignore UEFA from OpenSea, otherwise the token type would be saved wrongly as `.erc721` instead of `.erc721ForTickets`
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(.init(hasError: false, result: assetsExcludingUefa))
}
})
.recover { _ -> Promise<OpenSea.Response<OpenSeaNonFungiblesToAddress>> in
//NOTE: return some already fetched amount
let assetsExcludingUefa = assets.filter { !$0.key.isUEFATicketContract }
return .value(.init(hasError: true, result: assetsExcludingUefa))
}
}
}
fileprivate extension PromiseKit.Result {
var isRejected: Bool {
switch self {
case .fulfilled:
return false
case .rejected:
return true
}
}
}
extension OpenSea {
//NOTE: we want to keep response data even when request has failure while performing multiple page, that is why we use `hasError` flag to determine wether data can be saved to local storage with replacing or merging with existing data
struct Response<T> {
let hasError: Bool
let result: T
}
}

@ -4,7 +4,18 @@ import Foundation
import BigInt
import SwiftyJSON
//Some fields are duplicated across token IDs within the same contract like the contractName, symbol, contractImageUrl, etc. The space savings in the database aren't work the normalization
struct OpenSeaNonFungible: Codable, NonFungibleFromJson {
struct OpenSeaNonFungible: Codable, Equatable, Hashable, NonFungibleFromJson {
func hash(into hasher: inout Hasher) {
hasher.combine(tokenId)
hasher.combine(tokenType.rawValue)
hasher.combine(value.description)
}
static func == (lhs: OpenSeaNonFungible, rhs: OpenSeaNonFungible) -> Bool {
return lhs.tokenId == rhs.tokenId
}
//Not every token might used the same name. This is just common in OpenSea
public static let generationTraitName = "generation"
public static let cooldownIndexTraitName = "cooldown_index"

@ -932,6 +932,9 @@ extension InCoordinator: TokensCoordinatorDelegate {
func viewWillAppearOnce(in coordinator: TokensCoordinator) {
eventSourceCoordinator.start()
eventSourceCoordinatorForActivities?.start()
walletBalanceService.refreshBalance(for: wallet).done { _ in
//no-op
}.cauterize()
}
func whereAreMyTokensSelected(in coordinator: TokensCoordinator) {

@ -14,6 +14,10 @@ struct AddressAndRPCServer: Hashable, Codable, CustomStringConvertible {
var description: String {
return "\(address.eip55String)-\(server)"
}
func hash(into hasher: inout Hasher) {
hasher.combine(description)
}
}
extension AddressAndRPCServer: Equatable {

Loading…
Cancel
Save