diff --git a/AlphaWallet.xcodeproj/project.pbxproj b/AlphaWallet.xcodeproj/project.pbxproj index 8b6541295..1404bae30 100644 --- a/AlphaWallet.xcodeproj/project.pbxproj +++ b/AlphaWallet.xcodeproj/project.pbxproj @@ -263,6 +263,7 @@ 5E7C71A2EAA5124E07AA54B6 /* Favicon.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7BAC4E511FE8446D212F /* Favicon.swift */; }; 5E7C71A6B0BDF301747A49AE /* ScreenChecker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C77E1E6194F5A1DC8D645 /* ScreenChecker.swift */; }; 5E7C71A7D2BD6FCE3980CC51 /* ImportWalletHelpBubbleViewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7A16ABC8BD5D508AA641 /* ImportWalletHelpBubbleViewViewModel.swift */; }; + 5E7C71AE26F2201CFAC81C99 /* Erc721Contract.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C70360398F1FF5493FB5A /* Erc721Contract.swift */; }; 5E7C71AE5E97BDA5BB685A9F /* Dapp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7D8D618A8A8D55479CDF /* Dapp.swift */; }; 5E7C71B412DAE4BD7920436B /* ABIError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7558286761EF1ADD2988 /* ABIError.swift */; }; 5E7C71B52A77008694BFA5D1 /* TokensDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C74B9EB81C51E956566E7 /* TokensDataStore.swift */; }; @@ -304,6 +305,7 @@ 5E7C7307F0253556A6BC57A9 /* AlphaWalletAddressExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7EE6BFC8BB79CD1C5565 /* AlphaWalletAddressExtension.swift */; }; 5E7C73092E428F9519787284 /* Core.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C799E4784815CB0202820 /* Core.swift */; }; 5E7C730C6AEF556AFB9A4B2C /* LocalesViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7BF09AD68C113D58344C /* LocalesViewController.swift */; }; + 5E7C730CA247D13ECBCB94ED /* NonFungibleFromJson.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7B38E58247C6A3715BC4 /* NonFungibleFromJson.swift */; }; 5E7C7317533D24B6A292F88D /* UIStackView+Array.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C73ED9226646D562B5A3C /* UIStackView+Array.swift */; }; 5E7C731D0F6128BE8885A2D3 /* ServersCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7B8FD1E2BCC325DF4EE4 /* ServersCoordinator.swift */; }; 5E7C732BD09AABEEE6096BF4 /* ServersViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C74C0C1803DD17FE9EBA7 /* ServersViewController.swift */; }; @@ -313,12 +315,14 @@ 5E7C7350C5F9ADE212A0F1CA /* CallForAssetAttributeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C74F4900AB6D34CDD3674 /* CallForAssetAttributeCoordinator.swift */; }; 5E7C7376B566E5A59CC8F463 /* ImportMagicTokenViewControllerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C72D0E7CA03ADE5CFAE7A /* ImportMagicTokenViewControllerViewModel.swift */; }; 5E7C738BCA59B1DE116ECC96 /* WhereIsWalletAddressFoundOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C76052512831B707659CA /* WhereIsWalletAddressFoundOverlayView.swift */; }; + 5E7C738FFC4292F6B0D6BC4B /* Erc721TokenIdsFetcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C75E3F2155466F46BB139 /* Erc721TokenIdsFetcher.swift */; }; 5E7C739447F6BADDBBBF7278 /* AssetDefinitionsOverridesViewCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7695F7C45A31C7EAF97F /* AssetDefinitionsOverridesViewCellViewModel.swift */; }; 5E7C739611741D5E61900992 /* Eip681Parser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7AE608A39793F8BA3911 /* Eip681Parser.swift */; }; 5E7C73AB79C2C5FC6C76CDFF /* ERC20-TokenScript.tsml in Resources */ = {isa = PBXBuildFile; fileRef = 5E7C78795F6336DDBE2EB4C5 /* ERC20-TokenScript.tsml */; }; 5E7C73CE76679D1E1D6714F7 /* SolidityType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7C43F1B371836552CC18 /* SolidityType.swift */; }; 5E7C73E7A68C56162FA2E845 /* SingleChainTransactionEtherscanDataCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C74259A3F3B0277B0E8C5 /* SingleChainTransactionEtherscanDataCoordinator.swift */; }; 5E7C73F6762376F5B2B4214D /* EtherKeystore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7BA8A301CEEDE36D76A3 /* EtherKeystore.swift */; }; + 5E7C73F723684B295A8DEEB3 /* GetERC721TokenUri.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7870764B15D5E213604B /* GetERC721TokenUri.swift */; }; 5E7C73FB906BF267F28B0205 /* ActivityRowType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C762AA80515C4E0A87C44 /* ActivityRowType.swift */; }; 5E7C7402B29A987B0AF7061D /* VerifiableStatusViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7CC48CA7A1EA7D539C87 /* VerifiableStatusViewController.swift */; }; 5E7C740398B56DAED1D0C75A /* XmlContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7FBADEDE47DC37B9197A /* XmlContext.swift */; }; @@ -554,6 +558,7 @@ 5E7C7D6AC52076681FE8C43E /* AssetDefinitionStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7716E500124CA910FB2C /* AssetDefinitionStore.swift */; }; 5E7C7D71D3184F44C397FFE7 /* HelpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C715F395B973FB61056CF /* HelpViewController.swift */; }; 5E7C7D7A68C7780C293301F7 /* HDWalletTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C75DE215F0AAEF284948F /* HDWalletTest.swift */; }; + 5E7C7D7D1531464B489871EA /* NonFungibleFromTokenUri.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C73EA8FD43F18DDEB6965 /* NonFungibleFromTokenUri.swift */; }; 5E7C7D8173CB1089D622DA38 /* HelpViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7646352F10C96B5FC6F6 /* HelpViewCell.swift */; }; 5E7C7D918B514B4C2F8DF296 /* EventSourceCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7D4B3B2FEA6D4FDAE01C /* EventSourceCoordinator.swift */; }; 5E7C7DC485AD4401A3F6D071 /* TokenViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5E7C7EC53B2B5DFAAC7965EC /* TokenViewController.swift */; }; @@ -1118,6 +1123,7 @@ 5E7C702300BB7DB0FD7788EF /* XMLHandlerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XMLHandlerTest.swift; sourceTree = ""; }; 5E7C702A684DF27DC8ED4E42 /* TokenObjectTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenObjectTest.swift; sourceTree = ""; }; 5E7C703366F010BFEF6B06C6 /* OpenSeaNonFungibleTokenViewCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSeaNonFungibleTokenViewCell.swift; sourceTree = ""; }; + 5E7C70360398F1FF5493FB5A /* Erc721Contract.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Erc721Contract.swift; sourceTree = ""; }; 5E7C7037994332AE52798488 /* AssetAttributeSyntax.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetAttributeSyntax.swift; sourceTree = ""; }; 5E7C703BA1D0E9ACB7399155 /* TransferTokensCardQuantitySelectionViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransferTokensCardQuantitySelectionViewModel.swift; sourceTree = ""; }; 5E7C704499C81ACA3B08A752 /* TokenInstanceWebView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenInstanceWebView.swift; sourceTree = ""; }; @@ -1192,6 +1198,7 @@ 5E7C73DF5FBFE756097D32B1 /* EthCurrencyHelper.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EthCurrencyHelper.swift; sourceTree = ""; }; 5E7C73E57ADDF29E0A5FB87E /* OpenSeaNonFungibleTokenCardRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OpenSeaNonFungibleTokenCardRowView.swift; sourceTree = ""; }; 5E7C73E8500C2573331D800D /* Function.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Function.swift; sourceTree = ""; }; + 5E7C73EA8FD43F18DDEB6965 /* NonFungibleFromTokenUri.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonFungibleFromTokenUri.swift; sourceTree = ""; }; 5E7C73ED9226646D562B5A3C /* UIStackView+Array.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIStackView+Array.swift"; sourceTree = ""; }; 5E7C73EFA9494B31C683A287 /* TimeEntryField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = TimeEntryField.swift; path = Views/TimeEntryField.swift; sourceTree = ""; }; 5E7C740BBC5AAF5C545CCC6A /* EventOrigin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventOrigin.swift; sourceTree = ""; }; @@ -1244,6 +1251,7 @@ 5E7C75D384C0D727BB43305E /* SettingsHeaderView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsHeaderView.swift; sourceTree = ""; }; 5E7C75D9C3EA9FF978ECF8E5 /* DAI.tsml */ = {isa = PBXFileReference; lastKnownFileType = file.tsml; path = DAI.tsml; sourceTree = ""; }; 5E7C75DE215F0AAEF284948F /* HDWalletTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HDWalletTest.swift; sourceTree = ""; }; + 5E7C75E3F2155466F46BB139 /* Erc721TokenIdsFetcher.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Erc721TokenIdsFetcher.swift; sourceTree = ""; }; 5E7C75E7D995ABE0E6B7AD55 /* DappButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DappButton.swift; sourceTree = ""; }; 5E7C75F0DB84DB0F5449D6C1 /* WalletConnectSessionsViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnectSessionsViewController.swift; sourceTree = ""; }; 5E7C75F65E8C1E20EBA6A5F4 /* Scrollable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Scrollable.swift; sourceTree = ""; }; @@ -1306,6 +1314,7 @@ 5E7C78454B11E39CF8B5E695 /* EnterPasswordCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnterPasswordCoordinator.swift; sourceTree = ""; }; 5E7C785114A3266813AC92A6 /* AssetDefinitionInMemoryBackingStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDefinitionInMemoryBackingStore.swift; sourceTree = ""; }; 5E7C786486937661D0DCD4E2 /* Origin.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Origin.swift; sourceTree = ""; }; + 5E7C7870764B15D5E213604B /* GetERC721TokenUri.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GetERC721TokenUri.swift; sourceTree = ""; }; 5E7C78795F6336DDBE2EB4C5 /* ERC20-TokenScript.tsml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.tsml; path = "ERC20-TokenScript.tsml"; sourceTree = ""; }; 5E7C787E1D2A6529C07709DB /* AssetDefinitionDiskBackingStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AssetDefinitionDiskBackingStore.swift; sourceTree = ""; }; 5E7C788ADDEA0609433B1FDF /* VerifySeedPhraseViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = VerifySeedPhraseViewController.swift; sourceTree = ""; }; @@ -1381,6 +1390,7 @@ 5E7C7B211FF0FE5BE98BB7D0 /* aETH.tsml */ = {isa = PBXFileReference; lastKnownFileType = file.tsml; path = aETH.tsml; sourceTree = ""; }; 5E7C7B29A9E728402D144C05 /* AppLocale.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppLocale.swift; sourceTree = ""; }; 5E7C7B3302309706CA0F972A /* TokensViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokensViewController.swift; sourceTree = ""; }; + 5E7C7B38E58247C6A3715BC4 /* NonFungibleFromJson.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NonFungibleFromJson.swift; sourceTree = ""; }; 5E7C7B43816C35C3FE2EFFBE /* TokenInstanceAction.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenInstanceAction.swift; sourceTree = ""; }; 5E7C7B54826BFDD53DF3E5BF /* DiscoverDappCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoverDappCell.swift; sourceTree = ""; }; 5E7C7B5838E12930000D5029 /* TokenViewControllerTransactionCellViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TokenViewControllerTransactionCellViewModel.swift; sourceTree = ""; }; @@ -2159,6 +2169,7 @@ 2977CAF01F7E0C2E009682A0 /* Views */, 442FCB1A0F74A68425907AC9 /* Helpers */, 5E7C7E2DCCE0D775ECF83088 /* WalletFilter.swift */, + 5E7C72C3D9C939508F8EBFAC /* Models */, ); path = Tokens; sourceTree = ""; @@ -2446,6 +2457,8 @@ 87A0C93125AEF1E400E73F60 /* EventInstanceValue.swift */, 8703F663261319B80082EE25 /* AddressAndRPCServer.swift */, 8703F66626135B330082EE25 /* ChartHistory.swift */, + 5E7C73EA8FD43F18DDEB6965 /* NonFungibleFromTokenUri.swift */, + 5E7C7B38E58247C6A3715BC4 /* NonFungibleFromJson.swift */, ); path = Types; sourceTree = ""; @@ -2698,6 +2711,7 @@ 2963A2891FC402940095447D /* LocalizedOperationObject.swift */, 294DFBA21FE0E2EA004CEB56 /* TransactionValue.swift */, 77872D222023F43B0032D687 /* TransactionsTracker.swift */, + 5E7C75E3F2155466F46BB139 /* Erc721TokenIdsFetcher.swift */, ); path = Types; sourceTree = ""; @@ -3189,6 +3203,14 @@ path = OpenSea; sourceTree = ""; }; + 5E7C72C3D9C939508F8EBFAC /* Models */ = { + isa = PBXGroup; + children = ( + 5E7C70360398F1FF5493FB5A /* Erc721Contract.swift */, + ); + path = Models; + sourceTree = ""; + }; 5E7C7372BDD012864CBFE456 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -3608,6 +3630,7 @@ 8499DA6A661E357C9D69BA18 /* GetERC721ForTicketsBalance.swift */, 8499D5AD5EA8967FDB7FA6E2 /* GetInterfaceSupported165Encode.swift */, 5E7C7C097681EC7E022B6C44 /* ENSReverseLookupEncode.swift */, + 5E7C7870764B15D5E213604B /* GetERC721TokenUri.swift */, ); path = "web3swift-pod"; sourceTree = ""; @@ -5450,6 +5473,11 @@ 5E7C7C60BAF11B0BD135FC1E /* GroupActivityViewCell.swift in Sources */, 5E7C7BFE70D7C2CC0C84B72A /* GroupActivityCellViewModel.swift in Sources */, 5E7C7DEE91E5A1B4A41826EB /* ActivityRowModel.swift in Sources */, + 5E7C7D7D1531464B489871EA /* NonFungibleFromTokenUri.swift in Sources */, + 5E7C730CA247D13ECBCB94ED /* NonFungibleFromJson.swift in Sources */, + 5E7C71AE26F2201CFAC81C99 /* Erc721Contract.swift in Sources */, + 5E7C73F723684B295A8DEEB3 /* GetERC721TokenUri.swift in Sources */, + 5E7C738FFC4292F6B0D6BC4B /* Erc721TokenIdsFetcher.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/AlphaWallet/Core/Initializers/MigrationInitializer.swift b/AlphaWallet/Core/Initializers/MigrationInitializer.swift index 1be0113b8..4b0f6378d 100644 --- a/AlphaWallet/Core/Initializers/MigrationInitializer.swift +++ b/AlphaWallet/Core/Initializers/MigrationInitializer.swift @@ -13,7 +13,7 @@ class MigrationInitializer: Initializer { } func perform() { - config.schemaVersion = 8 + config.schemaVersion = 9 //NOTE: use [weak self] to avoid memory leak config.migrationBlock = { [weak self] migration, oldSchemaVersion in guard let strongSelf = self else { return } @@ -80,6 +80,10 @@ class MigrationInitializer: Initializer { } migration.deleteData(forType: EventActivity.className()) } + + if oldSchemaVersion < 9 { + //no-op + } } } } diff --git a/AlphaWallet/EtherClient/TrustClient/AlphaWalletService.swift b/AlphaWallet/EtherClient/TrustClient/AlphaWalletService.swift index 9119bee92..7be2fc127 100644 --- a/AlphaWallet/EtherClient/TrustClient/AlphaWalletService.swift +++ b/AlphaWallet/EtherClient/TrustClient/AlphaWalletService.swift @@ -53,7 +53,7 @@ extension AlphaWalletService: TargetType { case .getTransactions(_, let server, _, _, _, _): switch server { case .main, .classic, .callisto, .kovan, .ropsten, .custom, .rinkeby, .poa, .sokol, .goerli, .xDai, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .heco, .heco_testnet, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet: - return "/api" + return "" } case .register: return "/push/register" diff --git a/AlphaWallet/EtherClient/TrustClient/Models/LocalizedOperation.swift b/AlphaWallet/EtherClient/TrustClient/Models/LocalizedOperation.swift index 37f3688d2..f2110608b 100644 --- a/AlphaWallet/EtherClient/TrustClient/Models/LocalizedOperation.swift +++ b/AlphaWallet/EtherClient/TrustClient/Models/LocalizedOperation.swift @@ -27,6 +27,7 @@ struct LocalizedOperation: Decodable { case to case type case value + //case tokenID case contract } diff --git a/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift b/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift index f07afafcb..dd376fe4e 100644 --- a/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift +++ b/AlphaWallet/EtherClient/TrustClient/Models/RawTransaction.swift @@ -100,7 +100,7 @@ extension TransactionInstance { func generateLocalizedOperation(value: BigUInt, contract: AlphaWallet.Address, to recipient: AlphaWallet.Address, functionCall: DecodedFunctionCall) -> Promise<[LocalizedOperationObjectInstance]> { if let token = tokensStorage.tokenThreadSafe(forContract: contract) { let operationType = mapTokenTypeToTransferOperationType(token.type, functionCall: functionCall) - let result = LocalizedOperationObjectInstance(from: transaction.from, to: recipient.eip55String, contract: contract, type: operationType.rawValue, value: String(value), symbol: token.symbol, name: token.name, decimals: token.decimals) + let result = LocalizedOperationObjectInstance(from: transaction.from, to: recipient.eip55String, contract: contract, type: operationType.rawValue, value: String(value), tokenId: "", symbol: token.symbol, name: token.name, decimals: token.decimals) return .value([result]) } else { let getContractName = tokensStorage.getContractName(for: contract) @@ -112,7 +112,7 @@ extension TransactionInstance { when(fulfilled: getContractName, getContractSymbol, getDecimals, getTokenType) }.then { name, symbol, decimals, tokenType -> Promise<[LocalizedOperationObjectInstance]> in let operationType = mapTokenTypeToTransferOperationType(tokenType, functionCall: functionCall) - let result = LocalizedOperationObjectInstance(from: transaction.from, to: recipient.eip55String, contract: contract, type: operationType.rawValue, value: String(value), symbol: symbol, name: name, decimals: Int(decimals)) + let result = LocalizedOperationObjectInstance(from: transaction.from, to: recipient.eip55String, contract: contract, type: operationType.rawValue, value: String(value), tokenId: "", symbol: symbol, name: name, decimals: Int(decimals)) return .value([result]) }.recover { _ -> Promise<[LocalizedOperationObjectInstance]> in //NOTE: Return an empty array when failure to fetch contracts data, instead of failing whole TransactionInstance creating diff --git a/AlphaWallet/InCoordinator.swift b/AlphaWallet/InCoordinator.swift index 5cc641080..59f689396 100644 --- a/AlphaWallet/InCoordinator.swift +++ b/AlphaWallet/InCoordinator.swift @@ -318,6 +318,7 @@ class InCoordinator: NSObject, Coordinator { transactionsStorages = .init() for each in config.enabledServers { let transactionsStorage = createTransactionsStorage(server: each) + //TODO why do we remove such transactions? especially `.failed` and `.unknown`? transactionsStorage.removeTransactions(for: [.failed, .pending, .unknown]) transactionsStorages[each] = transactionsStorage } @@ -377,6 +378,15 @@ class InCoordinator: NSObject, Coordinator { //TODO rename this generic name to reflect that it's for event instances, not for event activity. A few other related ones too setUpEventSourceCoordinator() setUpEventSourceCoordinatorForActivities() + setUpErc721TokenIdsFetcher() + } + + private func setUpErc721TokenIdsFetcher() { + for each in config.enabledServers { + let tokenStorage = tokensStorages[each] + let transactionStorage = transactionsStorages[each] + tokenStorage.erc721TokenIdsFetcher = transactionStorage + } } private func setupEventsStorages() { @@ -423,7 +433,7 @@ class InCoordinator: NSObject, Coordinator { func showTabBar(animated: Bool) { navigationController.setViewControllers([accountsCoordinator.accountsViewController], animated: false) navigationController.pushViewController(tabBarController, animated: animated) - + navigationController.setNavigationBarHidden(true, animated: true) let inCoordinatorViewModel = InCoordinatorViewModel() diff --git a/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721BalanceEncode.swift b/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721BalanceEncode.swift index b5e37cf86..904a30990 100644 --- a/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721BalanceEncode.swift +++ b/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721BalanceEncode.swift @@ -8,4 +8,4 @@ import Foundation struct GetERC721Balance { let abi = "[{\"constant\":true,\"inputs\":[{\"name\":\"_owner\",\"type\":\"address\"}],\"name\":\"balanceOf\",\"outputs\":[{\"name\":\"\",\"type\":\"uint256\"}],\"payable\":false,\"stateMutability\":\"view\",\"type\":\"function\"}]" let name = "balanceOf" -} +} \ No newline at end of file diff --git a/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721TokenUri.swift b/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721TokenUri.swift new file mode 100644 index 000000000..da4e79d52 --- /dev/null +++ b/AlphaWallet/RPC/Commands/web3swift-pod/GetERC721TokenUri.swift @@ -0,0 +1,13 @@ +// Copyright © 2021 Stormbird PTE. LTD. + +import Foundation + +struct GetERC721TokenUri { + let abi = "[ { \"constant\": true, \"inputs\": [ {\"name\": \"\",\"type\": \"uint256\"}, ], \"name\": \"tokenURI\", \"outputs\": [{\"name\": \"\", \"type\": \"string\"}], \"type\": \"function\" } ]\n" + let name = "tokenURI" +} + +struct GetERC721Uri { + let abi = "[ { \"constant\": true, \"inputs\": [ {\"name\": \"\",\"type\": \"uint256\"}, ], \"name\": \"uri\", \"outputs\": [{\"name\": \"\", \"type\": \"string\"}], \"type\": \"function\" } ]\n" + let name = "uri" +} \ No newline at end of file diff --git a/AlphaWallet/Settings/Types/Config.swift b/AlphaWallet/Settings/Types/Config.swift index 0d4d668c2..5bc9c2526 100644 --- a/AlphaWallet/Settings/Types/Config.swift +++ b/AlphaWallet/Settings/Types/Config.swift @@ -82,6 +82,10 @@ struct Config { "\(Keys.lastFetchedAutoDetectedTransactedTokenErc20BlockNumber)-\(wallet.eip55String)" } + private static func generateLastFetchedErc721InteractionBlockNumberKey(_ wallet: AlphaWallet.Address) -> String { + "\(Keys.lastFetchedAutoDetectedTransactedTokenErc721BlockNumber)-\(wallet.eip55String)" + } + private static func generateLastFetchedAutoDetectedTransactedTokenErc20BlockNumberKey(_ wallet: AlphaWallet.Address) -> String { "\(Keys.lastFetchedAutoDetectedTransactedTokenErc20BlockNumber)-\(wallet.eip55String)" } @@ -101,6 +105,17 @@ struct Config { return dictionary["\(server.chainID)"]?.intValue } + static func setLastFetchedErc721InteractionBlockNumber(_ blockNumber: Int, server: RPCServer, wallet: AlphaWallet.Address, defaults: UserDefaults = UserDefaults.standard) { + var dictionary: [String: NSNumber] = (defaults.value(forKey: generateLastFetchedErc721InteractionBlockNumberKey(wallet)) as? [String: NSNumber]) ?? .init() + dictionary["\(server.chainID)"] = NSNumber(value: blockNumber) + defaults.set(dictionary, forKey: generateLastFetchedErc721InteractionBlockNumberKey(wallet)) + } + + static func getLastFetchedErc721InteractionBlockNumber(_ server: RPCServer, wallet: AlphaWallet.Address, defaults: UserDefaults = UserDefaults.standard) -> Int? { + guard let dictionary = defaults.value(forKey: generateLastFetchedErc721InteractionBlockNumberKey(wallet)) as? [String: NSNumber] else { return nil } + return dictionary["\(server.chainID)"]?.intValue + } + static func setLastFetchedAutoDetectedTransactedTokenErc20BlockNumber(_ blockNumber: Int, server: RPCServer, wallet: AlphaWallet.Address, defaults: UserDefaults = UserDefaults.standard) { var dictionary: [String: NSNumber] = (defaults.value(forKey: generateLastFetchedAutoDetectedTransactedTokenErc20BlockNumberKey(wallet)) as? [String: NSNumber]) ?? .init() dictionary["\(server.chainID)"] = NSNumber(value: blockNumber) @@ -134,6 +149,7 @@ struct Config { static let enabledServers = "enabledChains" static let lastFetchedErc20InteractionBlockNumber = "lastFetchedErc20InteractionBlockNumber" static let lastFetchedAutoDetectedTransactedTokenErc20BlockNumber = "lastFetchedAutoDetectedTransactedTokenErc20BlockNumber" + static let lastFetchedAutoDetectedTransactedTokenErc721BlockNumber = "lastFetchedAutoDetectedTransactedTokenErc721BlockNumber" static let lastFetchedAutoDetectedTransactedTokenNonErc20BlockNumber = "lastFetchedAutoDetectedTransactedTokenNonErc20BlockNumber" static let walletNames = "walletNames" static let useTaiChiNetwork = "useTaiChiNetworkKey" diff --git a/AlphaWallet/Settings/Types/Constants.swift b/AlphaWallet/Settings/Types/Constants.swift index c4bd9b9ae..52f01c06f 100644 --- a/AlphaWallet/Settings/Types/Constants.swift +++ b/AlphaWallet/Settings/Types/Constants.swift @@ -76,24 +76,12 @@ public struct Constants { //UEFA 721 balances function hash static let balances165Hash721Ticket = "0xc84aae17" - //etherscan-compatible APIs - public static let mainnetEtherscanAPI = "https://api-cn.etherscan.com/api?module=account&action=txlist&address=" - public static let ropstenEtherscanAPI = "https://ropsten.etherscan.io/api?module=account&action=txlist&address=" - public static let kovanEtherscanAPI = "https://api-kovan.etherscan.io/api?module=account&action=txlist&address=" - public static let rinkebyEtherscanAPI = "https://rinkeby.etherscan.io/api?module=account&action=txlist&address=" - public static let classicEtherscanAPI = "https://blockscout.com/etc/mainnet/api?module=account&action=txlist&address=" - public static let xDaiAPI = "https://blockscout.com/poa/dai/api?module=account&action=txlist&address=" - public static let poaNetworkCoreAPI = "https://blockscout.com/poa/core/api?module=account&action=txlist&address=" - public static let goerliEtherscanAPI = "https://api-goerli.etherscan.io/api?module=account&action=txlist&address=" - public static let artisSigma1NetworkCoreAPI = "https://explorer.sigma1.artis.network/api?module=account&action=txlist&address=" - public static let artisTau1NetworkCoreAPI = "https://explorer.tau1.artis.network/api?module=account&action=txlist&address=" - //etherscan-compatible erc20 transaction event APIs //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 public static let mainnetEtherscanAPIErc20Events = "https://api-cn.etherscan.com/api?module=account&action=tokentx&address=" - public static let ropstenEtherscanAPIErc20Events = "https://ropsten.etherscan.io/api?module=account&action=tokentx&address=" + public static let ropstenEtherscanAPIErc20Events = "https://api-ropsten.etherscan.io/api?module=account&action=tokentx&address=" public static let kovanEtherscanAPIErc20Events = "https://api-kovan.etherscan.io/api?module=account&action=tokentx&address=" - public static let rinkebyEtherscanAPIErc20Events = "https://rinkeby.etherscan.io/api?module=account&action=tokentx&address=" + public static let rinkebyEtherscanAPIErc20Events = "https://api-rinkeby.etherscan.io/api?module=account&action=tokentx&address=" public static let classicAPIErc20Events = "https://blockscout.com/etc/mainnet/api?module=account&action=tokentx&address=" public static let xDaiAPIErc20Events = "https://blockscout.com/poa/dai/api?module=account&action=tokentx&address=" public static let poaNetworkCoreAPIErc20Events = "https://blockscout.com/poa/core/api?module=account&action=tokentx&address=" @@ -101,8 +89,9 @@ public struct Constants { public static let artisSigma1NetworkCoreAPIErc20Events = "https://explorer.sigma1.artis.network/api?module=account&action=tokentx&address=" public static let artisTau1NetworkCoreAPIErc20Events = "https://explorer.tau1.artis.network/api?module=account&action=tokentx&address=" - //etherscan-compatible contract details web page public static let mainnetEtherscanTokenDetailsWebPageURL = "https://cn.etherscan.com/token/" + + //etherscan-compatible contract details web page public static let mainnetEtherscanContractDetailsWebPageURL = "https://cn.etherscan.com/address/" public static let kovanEtherscanContractDetailsWebPageURL = "https://kovan.etherscan.io/address/" public static let rinkebyEtherscanContractDetailsWebPageURL = "https://rinkeby.etherscan.io/address/" diff --git a/AlphaWallet/Settings/Types/RPCServers.swift b/AlphaWallet/Settings/Types/RPCServers.swift index 7f396b3b8..b12f56a76 100644 --- a/AlphaWallet/Settings/Types/RPCServers.swift +++ b/AlphaWallet/Settings/Types/RPCServers.swift @@ -30,6 +30,12 @@ enum RPCServer: Hashable, CaseIterable { case mumbai_testnet case custom(CustomRPC) + private enum EtherscanCompatibleType { + case etherscan + case blockscout + case unknown + } + //Using this property avoids direct reference to `.main`, which could be a sign of a possible crash — i.e. using `.main` when it is disabled by the user static var forResolvingEns: RPCServer { .main @@ -101,22 +107,11 @@ enum RPCServer: Hashable, CaseIterable { } } - var getEtherscanURL: String? { + var getEtherscanURL: URL? { switch self { - case .main: return Constants.mainnetEtherscanAPI - case .ropsten: return Constants.ropstenEtherscanAPI - case .rinkeby: return Constants.rinkebyEtherscanAPI - case .kovan: return Constants.kovanEtherscanAPI - case .poa: return Constants.poaNetworkCoreAPI - case .sokol: return nil - case .classic: return Constants.classicEtherscanAPI + case .main, .ropsten, .rinkeby, .kovan, .poa, .classic, .goerli, .xDai, .artis_sigma1, .artis_tau1, .polygon, .binance_smart_chain, .binance_smart_chain_testnet, .sokol: + return etherscanRoot.appendingPathComponent("?module=account&action=txlist&address=") case .callisto: return nil - case .goerli: return Constants.goerliEtherscanAPI - case .xDai: return Constants.xDaiAPI - case .artis_sigma1: return Constants.artisSigma1NetworkCoreAPI - case .artis_tau1: return Constants.artisTau1NetworkCoreAPI - case .binance_smart_chain: return nil - case .binance_smart_chain_testnet: return nil case .heco: return nil case .heco_testnet: return nil case .custom: return nil @@ -124,12 +119,10 @@ enum RPCServer: Hashable, CaseIterable { case .fantom_testnet: return nil case .avalanche: return nil case .avalanche_testnet: return nil - case .polygon: return nil case .mumbai_testnet: return nil } } - //TODO fix up all the networks var getEtherscanURLERC20Events: String? { switch self { case .main: return Constants.mainnetEtherscanAPIErc20Events @@ -153,11 +146,57 @@ enum RPCServer: Hashable, CaseIterable { case .fantom_testnet: return nil case .avalanche: return nil case .avalanche_testnet: return nil - case .polygon: return nil + case .polygon: return "https://explorer-mainnet.maticvigil.com/api/v2/transactions?module=account&action=tokentx&address=" case .mumbai_testnet: return nil } } + var etherscanRoot: URL { + let urlString: String = { + switch self { + case .main: return "https://api-cn.etherscan.com/api" + case .kovan: return "https://api-kovan.etherscan.io/api" + case .ropsten: return "https://api-ropsten.etherscan.io/api" + case .rinkeby: return "https://api-rinkeby.etherscan.io/api" + case .goerli: return "https://api-goerli.etherscan.io/api" + case .classic: return "https://blockscout.com/etc/mainnet/api" + case .callisto: return "https://callisto.trustwalletapp.com/api" + case .poa: return "https://blockscout.com/poa/core/api" + case .xDai: return "https://blockscout.com/poa/dai/api" + case .sokol: return "https://blockscout.com/poa/sokol/api" + case .artis_sigma1: return "https://explorer.sigma1.artis.network/api" + case .artis_tau1: return "https://explorer.tau1.artis.network/api" + case .binance_smart_chain: return "https://api.bscscan.com/api" + case .binance_smart_chain_testnet: return "https://api-testnet.bscscan.com/api" + case .heco_testnet: return "https://api-testnet.hecoinfo.com/api" + case .heco: return "https://api.hecoinfo.com/api" + case .custom: return "" // Enable? make optional + case .fantom: return "https://api.ftmscan.com/api" + //TODO fix etherscan-compatible API endpoint + case .fantom_testnet: return "https://explorer.testnet.fantom.network/tx/api" + //TODO fix etherscan-compatible API endpoint + case .avalanche: return "https://cchain.explorer.avax.network/tx/api" + //TODO fix etherscan-compatible API endpoint + case .avalanche_testnet: return "https://cchain.explorer.avax-test.network/tx/api" + case .polygon: return "https://explorer-mainnet.maticvigil.com/api/v2/" + case .mumbai_testnet: return "https://explorer-mumbai.maticvigil.com/api/v2" + } + }() + return URL(string: urlString)! + } + + //If Etherscan, action=tokentx for ERC20 and action=tokennfttx for ERC721. If Blockscout-compatible, action=tokentx includes both ERC20 and ERC721. tokennfttx is not supported. + var getEtherscanURLERC721Events: URL? { + switch erc721TransactionHistoryType { + case .etherscan: + return etherscanRoot.appendingPathComponent("?module=account&action=tokennfttx&address=") + case .blockscout: + return etherscanRoot.appendingPathComponent("/transactions?module=account&action=tokentx&&address=") + case .unknown: + return nil + } + } + var etherscanContractDetailsWebPageURL: String { switch self { case .main: return Constants.mainnetEtherscanContractDetailsWebPageURL @@ -196,13 +235,24 @@ enum RPCServer: Hashable, CaseIterable { } } + private var erc721TransactionHistoryType: EtherscanCompatibleType { + switch self { + case .main, .ropsten, .rinkeby, .kovan, .goerli, .fantom, .heco, .heco_testnet: + return .etherscan + case .poa, .sokol, .classic, .xDai, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .polygon, .mumbai_testnet: + return .blockscout + case .callisto, .custom, .fantom_testnet, .avalanche, .avalanche_testnet: + return .unknown + } + } + func etherscanAPIURLForTransactionList(for address: AlphaWallet.Address, startBlock: Int?) -> URL? { getEtherscanURL.flatMap { - var url = "\($0)\(address.eip55String)&apikey=\(Constants.Credentials.etherscanKey)" + var url = $0.appendingPathComponent("\(address.eip55String)&apikey=\(Constants.Credentials.etherscanKey)") if let startBlock = startBlock { - url = "\(url)&startBlock=\(startBlock)" + url = url.appendingPathComponent("&startBlock=\(startBlock)") } - return URL(string: url) + return url } } @@ -216,6 +266,16 @@ enum RPCServer: Hashable, CaseIterable { } } + func etherscanAPIURLForERC721TxList(for address: AlphaWallet.Address, startBlock: Int?) -> URL? { + getEtherscanURLERC721Events.flatMap { + var url = $0.appendingPathComponent("\(address.eip55String)&apikey=\(Constants.Credentials.etherscanKey)") + if let startBlock = startBlock { + url = $0.appendingPathComponent("\(url)&startBlock=\(startBlock)") + } + return url + } + } + func etherscanContractDetailsWebPageURL(for address: AlphaWallet.Address) -> URL { return URL(string: etherscanContractDetailsWebPageURL + address.eip55String)! } @@ -388,28 +448,13 @@ enum RPCServer: Hashable, CaseIterable { var transactionInfoEndpoints: URL { let urlString: String = { switch self { - case .main: return "https://api-cn.etherscan.com" - case .classic: return "https://blockscout.com/etc/mainnet/api" + case .main, .kovan, .ropsten, .rinkeby, .goerli, .classic, .poa, .xDai, .sokol, .artis_sigma1, .artis_tau1, .binance_smart_chain, .binance_smart_chain_testnet, .fantom, .polygon, .heco, .heco_testnet: + return etherscanRoot.absoluteString case .callisto: return "https://callisto.trustwalletapp.com" - case .kovan: return "https://api-kovan.etherscan.io" - case .ropsten: return "https://api-ropsten.etherscan.io" - case .rinkeby: return "https://api-rinkeby.etherscan.io" - case .poa: return "https://blockscout.com/poa/core/api" - case .xDai: return "https://blockscout.com/poa/dai/api" - case .sokol: return "https://blockscout.com/poa/sokol/api" - case .goerli: return "https://api-goerli.etherscan.io" - case .artis_sigma1: return "https://explorer.sigma1.artis.network/api" - case .artis_tau1: return "https://explorer.tau1.artis.network/api" - case .binance_smart_chain: return "https://bscscan.com/tx/" - case .binance_smart_chain_testnet: return "https://testnet.bscscan.com/tx/" - case .heco_testnet: return "https://scan-testnet.hecochain.com/tx/" - case .heco: return "https://scan.hecochain.com/tx/" case .custom: return "" // Enable? make optional - case .fantom: return "https://ftmscan.com/tx/" case .fantom_testnet: return "https://explorer.testnet.fantom.network/tx/" case .avalanche: return "https://cchain.explorer.avax.network/tx/" case .avalanche_testnet: return "https://cchain.explorer.avax-test.network/tx/" - case .polygon: return "https://explorer-mainnet.maticvigil.com/tx/" case .mumbai_testnet: return "https://explorer-mumbai.maticvigil.com/tx/" } }() diff --git a/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift b/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift index b39f0a029..07dbc603f 100644 --- a/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift +++ b/AlphaWallet/Tokens/Coordinators/GetContractInteractions.swift @@ -15,6 +15,7 @@ class GetContractInteractions { self.queue = queue } + //TODO rename this since it might include ERC721 (blockscout and compatible like Polygon's). Or can we make this really fetch ERC20, maybe by filtering the results? 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 } @@ -37,12 +38,94 @@ class GetContractInteractions { let transactions: [TransactionInstance] = filteredResult.map { result in let transactionJson = result.1 + //Blockscout (and compatible like Polygon's) includes ERC721 transfers + let operationType: OperationType + //TODO check have tokenID + no "value", cos those might be ERC1155? + if let tokenId = transactionJson["tokenID"].string, !tokenId.isEmpty { + operationType = .erc721TokenTransfer + } else { + operationType = .erc20TokenTransfer + } + + let localizedTokenObj = LocalizedOperationObjectInstance( + from: transactionJson["from"].stringValue, + to: transactionJson["to"].stringValue, + contract: AlphaWallet.Address(uncheckedAgainstNullAddress: transactionJson["contractAddress"].stringValue), + type: operationType.rawValue, + value: transactionJson["value"].stringValue, + tokenId: transactionJson["tokenID"].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, + //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 + ) + } + + completion(transactions) + case .failure: + completion([]) + } + }) + } + + //TODO Almost a duplicate of the the ERC20 version. De-dup maybe? + func getErc721Interactions(contractAddress: AlphaWallet.Address? = nil, address: AlphaWallet.Address, server: RPCServer, startBlock: Int? = nil, completion: @escaping ([TransactionInstance]) -> Void) { + guard let etherscanURL = server.etherscanAPIURLForERC721TxList(for: address, startBlock: startBlock) else { return } + + 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 + 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 transactions: [TransactionInstance] = filteredResult.map { result in + let transactionJson = result.1 + //Blockscout (and compatible like Polygon's) includes ERC721 transfers + let operationType: OperationType + //TODO check have tokenID + no "value", cos those might be ERC1155? + if let tokenId = transactionJson["tokenID"].string, !tokenId.isEmpty { + operationType = .erc721TokenTransfer + } else { + operationType = .erc20TokenTransfer + } + let localizedTokenObj = LocalizedOperationObjectInstance( from: transactionJson["from"].stringValue, to: transactionJson["to"].stringValue, contract: AlphaWallet.Address(uncheckedAgainstNullAddress: transactionJson["contractAddress"].stringValue), - type: OperationType.erc20TokenTransfer.rawValue, + type: operationType.rawValue, value: transactionJson["value"].stringValue, + tokenId: transactionJson["tokenID"].stringValue, symbol: transactionJson["tokenSymbol"].stringValue, name: transactionJson["tokenName"].stringValue, decimals: transactionJson["tokenDecimal"].intValue diff --git a/AlphaWallet/Tokens/Coordinators/GetERC721BalanceCoordinator.swift b/AlphaWallet/Tokens/Coordinators/GetERC721BalanceCoordinator.swift index 642f2dd95..387e9cfd2 100644 --- a/AlphaWallet/Tokens/Coordinators/GetERC721BalanceCoordinator.swift +++ b/AlphaWallet/Tokens/Coordinators/GetERC721BalanceCoordinator.swift @@ -5,6 +5,7 @@ import Foundation import BigInt +import PromiseKit import Result class GetERC721BalanceCoordinator { @@ -17,7 +18,7 @@ class GetERC721BalanceCoordinator { func getERC721TokenBalance( for address: AlphaWallet.Address, contract: AlphaWallet.Address, - completion: @escaping (Result) -> Void + completion: @escaping (ResultResult.t) -> Void ) { let function = GetERC721Balance() callSmartContract(withServer: server, contract: contract, functionName: function.name, abiString: function.abi, parameters: [address.eip55String] as [AnyObject], timeout: TokensDataStore.fetchContractDataTimeout).done { balanceResult in @@ -35,4 +36,4 @@ class GetERC721BalanceCoordinator { return BigUInt(0) } } -} +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Helpers/TokenAdaptor.swift b/AlphaWallet/Tokens/Helpers/TokenAdaptor.swift index 60a8e1ca2..d1a3aa020 100644 --- a/AlphaWallet/Tokens/Helpers/TokenAdaptor.swift +++ b/AlphaWallet/Tokens/Helpers/TokenAdaptor.swift @@ -24,19 +24,19 @@ class TokenAdaptor { public func getTokenHolders(forWallet account: Wallet, sourceFromEvents: Bool = true) -> [TokenHolder] { switch token.type { case .nativeCryptocurrency, .erc20, .erc875, .erc721ForTickets: - return getNotSupportedByOpenSeaTokenHolders(forWallet: account) + return getNotSupportedByNonFungibleJsonTokenHolders(forWallet: account) case .erc721: - let tokenType = OpenSeaSupportedNonFungibleTokenHandling(token: token) + let tokenType = NonFungibleFromJsonSupportedTokenHandling(token: token) switch tokenType { - case .supportedByOpenSea: - return getSupportedByOpenSeaTokenHolders(forWallet: account, sourceFromEvents: sourceFromEvents) - case .notSupportedByOpenSea: - return getNotSupportedByOpenSeaTokenHolders(forWallet: account) + case .supported: + return getSupportedByNonFungibleJsonTokenHolders(forWallet: account, sourceFromEvents: sourceFromEvents) + case .notSupported: + return getNotSupportedByNonFungibleJsonTokenHolders(forWallet: account) } } } - private func getNotSupportedByOpenSeaTokenHolders(forWallet account: Wallet) -> [TokenHolder] { + private func getNotSupportedByNonFungibleJsonTokenHolders(forWallet account: Wallet) -> [TokenHolder] { let balance = token.balance var tokens = [Token]() switch token.type { @@ -66,12 +66,12 @@ class TokenAdaptor { } } - private func getSupportedByOpenSeaTokenHolders(forWallet account: Wallet, sourceFromEvents: Bool) -> [TokenHolder] { + private func getSupportedByNonFungibleJsonTokenHolders(forWallet account: Wallet, sourceFromEvents: Bool) -> [TokenHolder] { let balance = token.balance var tokens = [Token]() for item in balance { let jsonString = item.balance - if let token = getTokenForOpenSeaNonFungible(forJSONString: jsonString, inWallet: account, server: self.token.server, sourceFromEvents: sourceFromEvents) { + if let token = getTokenForNonFungible(forJSONString: jsonString, inWallet: account, server: self.token.server, sourceFromEvents: sourceFromEvents) { tokens.append(token) } } @@ -157,8 +157,9 @@ class TokenAdaptor { XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore).getToken(name: name, symbol: symbol, fromTokenIdOrEvent: tokenIdOrEvent, index: index, inWallet: account, server: server, tokenType: token.type) } - private func getTokenForOpenSeaNonFungible(forJSONString jsonString: String, inWallet account: Wallet, server: RPCServer, sourceFromEvents: Bool) -> Token? { - guard let data = jsonString.data(using: .utf8), let nonFungible = try? JSONDecoder().decode(OpenSeaNonFungible.self, from: data) else { return nil } + private func getTokenForNonFungible(forJSONString jsonString: String, inWallet account: Wallet, server: RPCServer, sourceFromEvents: Bool) -> Token? { + guard let data = jsonString.data(using: .utf8), let nonFungible = nonFungible(fromJsonData: data) else { return nil } + let xmlHandler = XMLHandler(token: token, assetDefinitionStore: assetDefinitionStore) let event: EventInstance? if sourceFromEvents, let attributeWithEventSource = xmlHandler.attributesWithEventSource.first, let eventFilter = attributeWithEventSource.eventOrigin?.eventFilter, let eventName = attributeWithEventSource.eventOrigin?.eventName, let eventContract = attributeWithEventSource.eventOrigin?.contract { diff --git a/AlphaWallet/Tokens/Models/Erc721Contract.swift b/AlphaWallet/Tokens/Models/Erc721Contract.swift new file mode 100644 index 000000000..5cd7c0b7a --- /dev/null +++ b/AlphaWallet/Tokens/Models/Erc721Contract.swift @@ -0,0 +1,48 @@ +// Copyright © 2021 Stormbird PTE. LTD. + +import Foundation +import PromiseKit + +class Erc721Contract { + private let server: RPCServer + + init(server: RPCServer) { + self.server = server + } + + func getErc721TokenUri(for tokenId: String, contract: AlphaWallet.Address) -> Promise { + firstly { + getErc721TokenUriImpl(for: tokenId, contract: contract) + }.recover { _ in + self.getErc721Uri(for: tokenId, contract: contract) + } + } + + private func getErc721TokenUriImpl(for tokenId: String, contract: AlphaWallet.Address) -> Promise { + let function = GetERC721TokenUri() + return firstly { + callSmartContract(withServer: server, contract: contract, functionName: function.name, abiString: function.abi, parameters: [tokenId] as [AnyObject], timeout: TokensDataStore.fetchContractDataTimeout) + }.map { uriResult -> URL in + let string = (uriResult["0"] as? String) ?? "" + if let url = URL(string: string) { + return url + } else { + throw Web3Error(description: "Error extracting tokenUri for contract \(contract.eip55String) tokenId: \(tokenId)") + } + } + } + + private func getErc721Uri(for tokenId: String, contract: AlphaWallet.Address) -> Promise { + let function = GetERC721Uri() + return firstly { + callSmartContract(withServer: server, contract: contract, functionName: function.name, abiString: function.abi, parameters: [tokenId] as [AnyObject], timeout: TokensDataStore.fetchContractDataTimeout) + }.map { uriResult -> URL in + let string = (uriResult["0"] as? String) ?? "" + if let url = URL(string: string) { + return url + } else { + throw Web3Error(description: "Error extracting tokenUri uri for contract \(contract.eip55String) tokenId: \(tokenId)") + } + } + } +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Types/NonFungibleFromJson.swift b/AlphaWallet/Tokens/Types/NonFungibleFromJson.swift new file mode 100644 index 000000000..e41b5daac --- /dev/null +++ b/AlphaWallet/Tokens/Types/NonFungibleFromJson.swift @@ -0,0 +1,29 @@ +// Copyright © 2021 Stormbird PTE. LTD. + +import Foundation + +//Shape of this originally created to match OpenSea's API output +protocol NonFungibleFromJson: Codable { + var tokenId: String { get } + var contractName: String { get } + var symbol: String { get } + var name: String { get } + var description: String { get } + var thumbnailUrl: String { get } + var imageUrl: String { get } + var contractImageUrl: String { get } + var externalLink: String { get } + var backgroundColor: String? { get } + var traits: [OpenSeaNonFungibleTrait] { get } + var generationTrait: OpenSeaNonFungibleTrait? { get } +} + +func nonFungible(fromJsonData jsonData: Data) -> NonFungibleFromJson? { + if let nonFungible = try? JSONDecoder().decode(OpenSeaNonFungible.self, from: jsonData) { + return nonFungible + } + if let nonFungible = try? JSONDecoder().decode(NonFungibleFromTokenUri.self, from: jsonData) { + return nonFungible + } + return nil +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Types/NonFungibleFromTokenUri.swift b/AlphaWallet/Tokens/Types/NonFungibleFromTokenUri.swift new file mode 100644 index 000000000..83f5b7676 --- /dev/null +++ b/AlphaWallet/Tokens/Types/NonFungibleFromTokenUri.swift @@ -0,0 +1,19 @@ +// Copyright © 2021 Stormbird PTE. LTD. + +import Foundation + +//To store the output from ERC721's `tokenURI()`. The output has to be massaged to fit here as the properties was designed for OpenSea +struct NonFungibleFromTokenUri: Codable, NonFungibleFromJson { + let tokenId: String + let contractName: String + let symbol: String + let name: String + let description: String = "" + let thumbnailUrl: String + let imageUrl: String + let contractImageUrl: String = "" + let externalLink: String = "" + let backgroundColor: String? = "" + let traits: [OpenSeaNonFungibleTrait] = .init() + let generationTrait: OpenSeaNonFungibleTrait? = nil +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Types/OpenSeaNonFungible.swift b/AlphaWallet/Tokens/Types/OpenSeaNonFungible.swift index 3f4e9cf00..fe0261e5a 100644 --- a/AlphaWallet/Tokens/Types/OpenSeaNonFungible.swift +++ b/AlphaWallet/Tokens/Types/OpenSeaNonFungible.swift @@ -3,7 +3,7 @@ import Foundation //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 { +struct OpenSeaNonFungible: Codable, NonFungibleFromJson { //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" @@ -32,4 +32,4 @@ struct OpenSeaNonFungibleTrait: Codable { struct OpenSeaError: Error { var localizedDescription: String -} +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Types/OpenSeaNonFungibleTokenHandling.swift b/AlphaWallet/Tokens/Types/OpenSeaNonFungibleTokenHandling.swift index 8c1e3fff6..1c31de426 100644 --- a/AlphaWallet/Tokens/Types/OpenSeaNonFungibleTokenHandling.swift +++ b/AlphaWallet/Tokens/Types/OpenSeaNonFungibleTokenHandling.swift @@ -32,20 +32,20 @@ enum OpenSeaBackedNonFungibleTokenHandling { } } -///Use this enum to "mark" where we handle non-fungible tokens supported by OpenSea differently instead of accessing the contract directly -///Even if there is a TokenScript file available for the contract, it is assumed to still be supported by OpenSea +///Use this enum to "mark" where we handle non-fungible tokens supported by JSON (either via OpenSea or with ERC721's `tokenURI`) +///Even if there is a TokenScript file available for the contract, it is assumed to still be supported by this way ///If there are other special casing for tokens that doesn't fit this model, create another enum type (not case) -enum OpenSeaSupportedNonFungibleTokenHandling { - case supportedByOpenSea - case notSupportedByOpenSea +enum NonFungibleFromJsonSupportedTokenHandling { + case supported + case notSupported init(token: TokenObject) { self = { if !token.balance.isEmpty && token.balance[0].balance.hasPrefix("{") { - return .supportedByOpenSea + return .supported } else { - return .notSupportedByOpenSea + return .notSupported } }() } -} +} \ No newline at end of file diff --git a/AlphaWallet/Tokens/Types/TokensDataStore.swift b/AlphaWallet/Tokens/Types/TokensDataStore.swift index 59dbbe500..45a6ad99b 100644 --- a/AlphaWallet/Tokens/Types/TokensDataStore.swift +++ b/AlphaWallet/Tokens/Types/TokensDataStore.swift @@ -21,6 +21,8 @@ protocol TokensDataStorePriceDelegate: class { // swiftlint:disable type_body_length class TokensDataStore { + typealias ContractAndJson = (contract: AlphaWallet.Address, json: String) + static let fetchContractDataTimeout = TimeInterval(4) private lazy var getNameCoordinator: GetNameCoordinator = { @@ -89,6 +91,7 @@ class TokensDataStore { let server: RPCServer weak var delegate: TokensDataStoreDelegate? weak var priceDelegate: TokensDataStorePriceDelegate? + weak var erc721TokenIdsFetcher: Erc721TokenIdsFetcher? //TODO why is this a dictionary? There seems to be only at most 1 key-value pair in the dictionary var tickers: [AddressAndRPCServer: CoinTicker] = .init() { didSet { @@ -359,6 +362,7 @@ class TokensDataStore { } } + //TODO should callers call tokenURI and so on, instead? func getERC721Balance(for address: AlphaWallet.Address, completion: @escaping (ResultResult<[String], AnyError>.t) -> Void) { withRetry(times: numberOfTimesToRetryFetchContractData) { [weak self] triggerRetry in guard let strongSelf = self else { return } @@ -536,7 +540,6 @@ class TokensDataStore { private func refreshBalanceForERC721Tokens(tokens: [TokenObject]) { assert(!tokens.contains { !$0.isERC721AndNotForTickets }) - guard OpenSea.isServerSupported(server) else { return } firstly { getTokensFromOpenSea() }.done { [weak self] contractToOpenSeaNonFungibles in @@ -548,26 +551,96 @@ class TokensDataStore { strongSelf.updateDelegate() }.cauterize() } + func foo1() { + firstly { + Alamofire.request("https://httpbin.org/get", method: .get).responseJSON() + //}.then { json, rsp in + // // + }.catch{ error in + //… + } + } - private func updateNonOpenSeaNonFungiblesBalance(erc721ContractsNotFoundInOpenSea: [AlphaWallet.Address], tokens: [TokenObject]) { - var count = 0 - for address in erc721ContractsNotFoundInOpenSea { - getERC721Balance(for: address) { [weak self] result in - guard let strongSelf = self else { return } - defer { - count += 1 - if count == erc721ContractsNotFoundInOpenSea.count { - strongSelf.updateDelegate() - } + private func updateNonOpenSeaNonFungiblesBalance(erc721ContractsNotFoundInOpenSea contracts: [AlphaWallet.Address], tokens: [TokenObject]) { + let promises = contracts.map { updateNonOpenSeaNonFungiblesBalance(contract: $0, tokens: tokens) } + firstly { + when(resolved: promises) + }.done { _ in + self.updateDelegate() + } + } + + private func updateNonOpenSeaNonFungiblesBalance(contract: AlphaWallet.Address, tokens: [TokenObject]) -> Promise { + guard let erc721TokenIdsFetcher = erc721TokenIdsFetcher else { return Promise { seal in } } + return firstly { + erc721TokenIdsFetcher.tokenIdsForErc721Token(contract: contract, inAccount: account.address) + }.then { tokenIds -> Promise<[ContractAndJson]> in + let guarantees: [Guarantee] = tokenIds.map { self.fetchNonFungibleJson(forTokenId: $0, address: contract, tokens: tokens) } + return when(fulfilled: guarantees) + }.done { listOfContractAndJsonResult in + var contractsAndJsons: [AlphaWallet.Address: [String]] = .init() + for each in listOfContractAndJsonResult { + if var listOfJson = contractsAndJsons[each.contract] { + listOfJson.append(each.json) + contractsAndJsons[each.contract] = listOfJson + } else { + contractsAndJsons[each.contract] = [each.json] } - switch result { - case .success(let balance): - if let token = tokens.first(where: { $0.contractAddress.sameContract(as: address) }) { - strongSelf.update(token: token, action: .nonFungibleBalance(balance)) + } + for (contract, jsons) in contractsAndJsons { + guard let tokenObject = tokens.first(where: { $0.contractAddress.sameContract(as: contract) }) else { continue } + self.update(token: tokenObject, action: .nonFungibleBalance(jsons)) + } + }.asVoid() + } + + private func fetchNonFungibleJson(forTokenId tokenId: String, address: AlphaWallet.Address, tokens: [TokenObject]) -> Guarantee { + firstly { + Erc721Contract(server: server).getErc721TokenUri(for: tokenId, contract: address) + }.then { + self.fetchTokenJson(forTokenId: tokenId, uri: $0, address: address, tokens: tokens) + }.recover { _ in + var jsonDictionary = JSON() + if let tokenObject = tokens.first(where: { $0.contractAddress.sameContract(as: address) }) { + jsonDictionary["tokenId"] = JSON(tokenId) + jsonDictionary["contractName"] = JSON(tokenObject.name) + jsonDictionary["symbol"] = JSON(tokenObject.symbol) + jsonDictionary["name"] = "" + jsonDictionary["imageUrl"] = "" + jsonDictionary["thumbnailUrl"] = "" + } + return .value((contract: address, json: jsonDictionary.rawString()!)) + } + } + + private func fetchTokenJson(forTokenId tokenId: String, uri: URL, address: AlphaWallet.Address, tokens: [TokenObject]) -> Promise { + struct Error: Swift.Error { + } + + return firstly { + Alamofire.request(uri, method: .get).responseData() + }.map { data, rsp in + if let json = try? JSON(data: data) { + if json["error"] == "Internal Server Error" { + throw Error() + } else { + var jsonDictionary = json + if let tokenObject = tokens.first(where: { $0.contractAddress.sameContract(as: address) }) { + jsonDictionary["tokenId"] = JSON(tokenId) + jsonDictionary["contractName"] = JSON(tokenObject.name) + jsonDictionary["symbol"] = JSON(tokenObject.symbol) + jsonDictionary["name"] = jsonDictionary["name"] + jsonDictionary["imageUrl"] = jsonDictionary["image"] + jsonDictionary["thumbnailUrl"] = jsonDictionary["image"] + } + if let jsonString = jsonDictionary.rawString() { + return (contract: address, json: jsonString) + } else { + throw Error() } - case .failure: - break } + } else { + throw Error() } } } diff --git a/AlphaWallet/Tokens/ViewControllers/TokenInstanceActionViewController.swift b/AlphaWallet/Tokens/ViewControllers/TokenInstanceActionViewController.swift index b37718dd8..76b63e7bc 100644 --- a/AlphaWallet/Tokens/ViewControllers/TokenInstanceActionViewController.swift +++ b/AlphaWallet/Tokens/ViewControllers/TokenInstanceActionViewController.swift @@ -65,11 +65,11 @@ class TokenInstanceActionViewController: UIViewController, TokenVerifiableStatus } var canPeekToken: Bool { - let tokenType = OpenSeaSupportedNonFungibleTokenHandling(token: tokenObject) + let tokenType = NonFungibleFromJsonSupportedTokenHandling(token: tokenObject) switch tokenType { - case .supportedByOpenSea: + case .supported: return true - case .notSupportedByOpenSea: + case .notSupported: return false } } diff --git a/AlphaWallet/Tokens/ViewControllers/TokenInstanceViewController.swift b/AlphaWallet/Tokens/ViewControllers/TokenInstanceViewController.swift index 928fffeea..4b0a4e550 100644 --- a/AlphaWallet/Tokens/ViewControllers/TokenInstanceViewController.swift +++ b/AlphaWallet/Tokens/ViewControllers/TokenInstanceViewController.swift @@ -43,11 +43,11 @@ class TokenInstanceViewController: UIViewController, TokenVerifiableStatusViewCo } var canPeekToken: Bool { - let tokenType = OpenSeaSupportedNonFungibleTokenHandling(token: tokenObject) + let tokenType = NonFungibleFromJsonSupportedTokenHandling(token: tokenObject) switch tokenType { - case .supportedByOpenSea: + case .supported: return true - case .notSupportedByOpenSea: + case .notSupported: return false } } diff --git a/AlphaWallet/Tokens/ViewControllers/TokensCardViewController.swift b/AlphaWallet/Tokens/ViewControllers/TokensCardViewController.swift index 820f70eef..d895b6307 100644 --- a/AlphaWallet/Tokens/ViewControllers/TokensCardViewController.swift +++ b/AlphaWallet/Tokens/ViewControllers/TokensCardViewController.swift @@ -68,11 +68,11 @@ class TokensCardViewController: UIViewController, TokenVerifiableStatusViewContr } var canPeekToken: Bool { - let tokenType = OpenSeaSupportedNonFungibleTokenHandling(token: tokenObject) + let tokenType = NonFungibleFromJsonSupportedTokenHandling(token: tokenObject) switch tokenType { - case .supportedByOpenSea: + case .supported: return true - case .notSupportedByOpenSea: + case .notSupported: return false } } diff --git a/AlphaWallet/Tokens/ViewControllers/TokensViewController.swift b/AlphaWallet/Tokens/ViewControllers/TokensViewController.swift index 4c1a098ef..3c6ca466a 100644 --- a/AlphaWallet/Tokens/ViewControllers/TokensViewController.swift +++ b/AlphaWallet/Tokens/ViewControllers/TokensViewController.swift @@ -398,7 +398,6 @@ extension TokensViewController: StatefulViewController { extension TokensViewController: UITableViewDelegate { func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) - let token = viewModel.item(for: indexPath.row, section: indexPath.section) delegate?.didSelect(token: token, in: self) } diff --git a/AlphaWallet/Tokens/Views/NonFungibleTokenViewCell.swift b/AlphaWallet/Tokens/Views/NonFungibleTokenViewCell.swift index a0cbbf835..971a4f533 100644 --- a/AlphaWallet/Tokens/Views/NonFungibleTokenViewCell.swift +++ b/AlphaWallet/Tokens/Views/NonFungibleTokenViewCell.swift @@ -63,7 +63,6 @@ class NonFungibleTokenViewCell: UITableViewCell { $0.alpha = viewModel.alpha } tokenIconImageView.subscribable = viewModel.iconImage - blockChainTagLabel.configure(viewModel: viewModel.blockChainTagViewModel) } } diff --git a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift index 51916c3ba..2c8994455 100644 --- a/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift +++ b/AlphaWallet/Transactions/Coordinators/SingleChainTransactionEtherscanDataCoordinator.swift @@ -25,6 +25,7 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData private let alphaWalletProvider = AlphaWalletProviderFactory.makeProvider() private var isAutoDetectingERC20Transactions: Bool = false + private var isAutoDetectingErc721Transactions: Bool = false private var isFetchingLatestTransactions = false var coordinators: [Coordinator] = [] weak var delegate: SingleChainTransactionDataCoordinatorDelegate? @@ -50,6 +51,7 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData if transactionsTracker.fetchingState != .done { fetchOlderTransactions(for: session.account.address) autoDetectERC20Transactions() + autoDetectErc721Transactions() } } @@ -79,11 +81,13 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData strongSelf.queue.async { strongSelf.fetchLatestTransactions() strongSelf.autoDetectERC20Transactions() + strongSelf.autoDetectErc721Transactions() } }, selector: #selector(Operation.main), userInfo: nil, repeats: true) } //TODO should this be added to the queue? + //TODO when blockscout-compatible, this includes ERC721 too. Maybe rename? private func autoDetectERC20Transactions() { guard !isAutoDetectingERC20Transactions else { return } isAutoDetectingERC20Transactions = true @@ -98,7 +102,7 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData let blockNumbers = result.map(\.blockNumber) if let minBlockNumber = blockNumbers.min(), let maxBlockNumber = blockNumbers.max() { firstly { - strongSelf.backFillErc20TransactionGroup(result, startBlock: minBlockNumber, endBlock: maxBlockNumber) + strongSelf.backFillTransactionGroup(result, startBlock: minBlockNumber, endBlock: maxBlockNumber) }.done(on: strongSelf.queue) { backFilledTransactions in Config.setLastFetchedErc20InteractionBlockNumber(maxBlockNumber, server: server, wallet: wallet) @@ -114,7 +118,37 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData } } - private func backFillErc20TransactionGroup(_ transactionsToFill: [TransactionInstance], startBlock: Int, endBlock: Int) -> Promise<[TransactionInstance]> { + private func autoDetectErc721Transactions() { + guard !isAutoDetectingErc721Transactions else { return } + isAutoDetectingErc721Transactions = true + + let server = session.server + let wallet = session.account.address + + let startBlock = Config.getLastFetchedErc721InteractionBlockNumber(session.server, wallet: wallet).flatMap { $0 + 1 } + GetContractInteractions(queue: self.queue).getErc721Interactions(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.backFillTransactionGroup(result, startBlock: minBlockNumber, endBlock: maxBlockNumber) + }.done(on: strongSelf.queue) { backFilledTransactions in + Config.setLastFetchedErc721InteractionBlockNumber(maxBlockNumber, server: server, wallet: wallet) + + strongSelf.update(items: backFilledTransactions) + }.cauterize() + .finally { + strongSelf.isAutoDetectingErc721Transactions = false + } + } else { + strongSelf.isAutoDetectingErc721Transactions = false + strongSelf.update(items: result) + } + } + } + + private func backFillTransactionGroup(_ transactionsToFill: [TransactionInstance], startBlock: Int, endBlock: Int) -> Promise<[TransactionInstance]> { return firstly { fetchTransactions(for: session.account.address, startBlock: startBlock, endBlock: endBlock, sortOrder: .asc) }.map(on: self.queue) { fillerTransactions -> [TransactionInstance] in @@ -164,7 +198,7 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData } private func filterTransactionsToPullContractsFrom(_ transactions: [TransactionInstance]) -> Promise<(transactions: [TransactionInstance], contractTypes: [AlphaWallet.Address: TokenType])> { - return Promise { seal in + Promise { seal in let contractsToAvoid = self.contractsToAvoid let filteredTransactions = transactions.filter { if let toAddressToCheck = AlphaWallet.Address(string: $0.to), contractsToAvoid.contains(toAddressToCheck) { @@ -177,21 +211,16 @@ class SingleChainTransactionEtherscanDataCoordinator: SingleChainTransactionData } //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, .fantom, .fantom_testnet, .avalanche, .avalanche_testnet, .polygon, .mumbai_testnet: - seal.fulfill((transactions: filteredTransactions, contractTypes: .init())) + 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) } } } diff --git a/AlphaWallet/Transactions/Storage/Transaction.swift b/AlphaWallet/Transactions/Storage/Transaction.swift index 578d06eee..b12b1586c 100644 --- a/AlphaWallet/Transactions/Storage/Transaction.swift +++ b/AlphaWallet/Transactions/Storage/Transaction.swift @@ -139,9 +139,9 @@ extension Transaction { if let functionCallMetaData = DecodedFunctionCall(data: data), let contract = contractOrRecipient, let token = tokensDataStore.tokenThreadSafe(forContract: contract) { switch functionCallMetaData.type { case .erc20Approve(let spender, let value): - return (operations: [LocalizedOperationObject(from: from.eip55String, to: spender.eip55String, contract: contract, type: OperationType.erc20TokenApprove.rawValue, value: String(value), symbol: token.symbol, name: token.name, decimals: token.decimals)], isErc20Interaction: true) + return (operations: [LocalizedOperationObject(from: from.eip55String, to: spender.eip55String, contract: contract, type: OperationType.erc20TokenApprove.rawValue, value: String(value), tokenId: "", symbol: token.symbol, name: token.name, decimals: token.decimals)], isErc20Interaction: true) 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) + return (operations: [LocalizedOperationObject(from: from.eip55String, to: recipient.eip55String, contract: contract, type: OperationType.erc20TokenTransfer.rawValue, value: String(value), tokenId: "", symbol: token.symbol, name: token.name, decimals: token.decimals)], isErc20Interaction: true) case .nativeCryptoTransfer, .others: break } diff --git a/AlphaWallet/Transactions/Storage/TransactionsStorage.swift b/AlphaWallet/Transactions/Storage/TransactionsStorage.swift index 357add8b1..474271e18 100644 --- a/AlphaWallet/Transactions/Storage/TransactionsStorage.swift +++ b/AlphaWallet/Transactions/Storage/TransactionsStorage.swift @@ -200,13 +200,32 @@ class TransactionsStorage { guard let contract = operation.contractAddress else { return nil } guard let name = operation.name else { return nil } guard let symbol = operation.symbol else { return nil } + let tokenType: TokenType + if let t = contractsAndTokenTypes[contract] { + tokenType = t + } else { + switch operation.operationType { + case .nativeCurrencyTokenTransfer: + tokenType = .nativeCryptocurrency + case .erc20TokenTransfer: + tokenType = .erc20 + case .erc20TokenApprove: + tokenType = .erc20 + case .erc721TokenTransfer: + tokenType = .erc721 + case .erc875TokenTransfer: + tokenType = .erc875 + case .unknown: + tokenType = .erc20 + } + } return TokenUpdate( address: contract, server: server, name: name, symbol: symbol, decimals: operation.decimals, - tokenType: contractsAndTokenTypes[contract] ?? .erc20 + tokenType: tokenType ) } return tokenUpdates @@ -245,3 +264,28 @@ class TransactionsStorage { } } } + +extension TransactionsStorage: Erc721TokenIdsFetcher { + func tokenIdsForErc721Token(contract: AlphaWallet.Address, inAccount account: AlphaWallet.Address) -> Promise<[String]> { + Promise { seal in + //Important to sort ascending to figure out ownership from transfers in and out + let transactions = objects + .filter("isERC20Interaction == true") + .sorted(byKeyPath: "date", ascending: true) + let operations: [LocalizedOperationObject] = transactions.flatMap { $0.localizedOperations.filter { $0.contractAddress?.sameContract(as: contract) ?? false } } + var tokenIds: Set = .init() + for each in operations { + let tokenId = each.tokenId + guard !tokenId.isEmpty else { continue } + if account.sameContract(as: each.from) { + tokenIds.remove(tokenId) + } else if account.sameContract(as: each.to) { + tokenIds.insert(tokenId) + } else { + //no-op + } + } + seal.fulfill(Array(tokenIds)) + } + } +} \ No newline at end of file diff --git a/AlphaWallet/Transactions/Types/Erc721TokenIdsFetcher.swift b/AlphaWallet/Transactions/Types/Erc721TokenIdsFetcher.swift new file mode 100644 index 000000000..2003ff5d0 --- /dev/null +++ b/AlphaWallet/Transactions/Types/Erc721TokenIdsFetcher.swift @@ -0,0 +1,8 @@ +// Copyright © 2021 Stormbird PTE. LTD. + +import Foundation +import PromiseKit + +protocol Erc721TokenIdsFetcher: class { + func tokenIdsForErc721Token(contract: AlphaWallet.Address, inAccount account: AlphaWallet.Address) -> Promise<[String]> +} \ No newline at end of file diff --git a/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift b/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift index 99662436e..ab1837a94 100644 --- a/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift +++ b/AlphaWallet/Transactions/Types/LocalizedOperationObject.swift @@ -10,6 +10,7 @@ class LocalizedOperationObject: Object { @objc dynamic var contract: String? = .none @objc dynamic var type: String = "" @objc dynamic var value: String = "" + @objc dynamic var tokenId: String = "" @objc dynamic var name: String? = .none @objc dynamic var symbol: String? = .none @objc dynamic var decimals: Int = 18 @@ -20,6 +21,7 @@ class LocalizedOperationObject: Object { contract: AlphaWallet.Address?, type: String, value: String, + tokenId: String, symbol: String?, name: String?, decimals: Int @@ -30,6 +32,7 @@ class LocalizedOperationObject: Object { self.contract = contract?.eip55String self.type = type self.value = value + self.tokenId = tokenId self.symbol = symbol self.name = name self.decimals = decimals @@ -42,6 +45,7 @@ class LocalizedOperationObject: Object { self.contract = object.contract self.type = object.type self.value = object.value + self.tokenId = object.tokenId self.symbol = object.symbol self.name = object.name self.decimals = object.decimals @@ -63,6 +67,7 @@ struct LocalizedOperationObjectInstance { var contract: String? = .none var type: String = "" var value: String = "" + var tokenId: String = "" var name: String? = .none var symbol: String? = .none var decimals: Int = 18 @@ -73,6 +78,7 @@ struct LocalizedOperationObjectInstance { self.contract = object.contract self.type = object.type self.value = object.value + self.tokenId = object.tokenId self.symbol = object.symbol self.name = object.name self.decimals = object.decimals @@ -84,6 +90,7 @@ struct LocalizedOperationObjectInstance { contract: AlphaWallet.Address?, type: String, value: String, + tokenId: String, symbol: String?, name: String?, decimals: Int @@ -93,6 +100,7 @@ struct LocalizedOperationObjectInstance { self.contract = contract?.eip55String self.type = type self.value = value + self.tokenId = tokenId self.symbol = symbol self.name = name self.decimals = decimals diff --git a/AlphaWallet/UI/TokenObject+UI.swift b/AlphaWallet/UI/TokenObject+UI.swift index c894fd7b5..15dded353 100644 --- a/AlphaWallet/UI/TokenObject+UI.swift +++ b/AlphaWallet/UI/TokenObject+UI.swift @@ -97,13 +97,15 @@ private class TokenImageFetcher { Promise { seal in switch type { case .erc721: - if let json = 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 = nonFungible(fromJsonData: data), !openSeaNonFungible.contractImageUrl.isEmpty { let request = URLRequest(url: URL(string: openSeaNonFungible.contractImageUrl)!) fetch(request: request).done { image in seal.fulfill(image) }.catch { _ in seal.reject(ImageAvailabilityError.notAvailable) } + } else { + seal.reject(ImageAvailabilityError.notAvailable) } case .nativeCryptocurrency, .erc20, .erc875, .erc721ForTickets: seal.reject(ImageAvailabilityError.notAvailable)