diff --git a/Features/Settings/Sources/Settings/ViewModels/DeveloperViewModel.swift b/Features/Settings/Sources/Settings/ViewModels/DeveloperViewModel.swift index 247a4eea8..dc608bab4 100644 --- a/Features/Settings/Sources/Settings/ViewModels/DeveloperViewModel.swift +++ b/Features/Settings/Sources/Settings/ViewModels/DeveloperViewModel.swift @@ -166,7 +166,8 @@ public final class DeveloperViewModel { fromValue: BigInt(2767611111).description, toAsset: AssetId.init(chain: .solana), toValue: BigInt(812312312).description, - provider: .none + provider: .none, + swapResult: .none ) ), createdAt: Date().addingTimeInterval(-122223) @@ -235,7 +236,8 @@ public final class DeveloperViewModel { fromValue: BigInt(276767623311111111).description, toAsset: AssetId.init(chain: .bitcoin), toValue: BigInt(32312312).description, - provider: .none + provider: .none, + swapResult: .none ) ), createdAt: Date().addingTimeInterval(-1344411) diff --git a/Features/Transactions/Package.swift b/Features/Transactions/Package.swift index a5f9c80d9..6b5158d8c 100644 --- a/Features/Transactions/Package.swift +++ b/Features/Transactions/Package.swift @@ -37,6 +37,7 @@ let package = Package( "PrimitivesComponents", .product(name: "ExplorerService", package: "ChainServices"), .product(name: "TransactionsService", package: "FeatureServices"), + .product(name: "SwapService", package: "FeatureServices"), .product(name: "WalletService", package: "FeatureServices"), "Preferences", "InfoSheet" @@ -49,7 +50,8 @@ let package = Package( .product(name: "PrimitivesTestKit", package: "Primitives"), .product(name: "PreferencesTestKit", package: "Preferences"), "Transactions", - "PrimitivesComponents" + "PrimitivesComponents", + .product(name: "SwapService", package: "FeatureServices") ] ), ] diff --git a/Features/Transactions/Sources/Types/TransactionSection.swift b/Features/Transactions/Sources/Types/TransactionSection.swift index e44c3707f..cf8a32910 100644 --- a/Features/Transactions/Sources/Types/TransactionSection.swift +++ b/Features/Transactions/Sources/Types/TransactionSection.swift @@ -20,6 +20,7 @@ public enum TransactionItem: Identifiable, Equatable, Sendable { case swapButton case date case status + case swapStatus case participant case memo case network diff --git a/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift b/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift index fde4e223a..13e300d5d 100644 --- a/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift +++ b/Features/Transactions/Sources/ViewModels/TransactionExplorerViewModel.swift @@ -18,18 +18,14 @@ public struct TransactionExplorerViewModel: Sendable { self.explorerService = explorerService } - private var swapProvider: String? { - guard case let .swap(metadata) = transactionViewModel.transaction.transaction.metadata else { - return nil - } - return metadata.provider - } - private var transactionLink: BlockExplorerLink { - explorerService.transactionUrl( - chain: transactionViewModel.transaction.transaction.assetId.chain, - hash: transactionViewModel.transaction.transaction.hash, - swapProvider: swapProvider + let chain = transactionViewModel.transaction.transaction.assetId.chain + let hash = transactionViewModel.transaction.transaction.hash + return explorerService.transactionLink( + chain: chain, + provider: transactionViewModel.transaction.transaction.swapProvider, + hash: hash, + recipient: transactionViewModel.transaction.transaction.to ) } diff --git a/Features/Transactions/Sources/ViewModels/TransactionSceneViewModel.swift b/Features/Transactions/Sources/ViewModels/TransactionSceneViewModel.swift index 444f4ebc5..16a92028e 100644 --- a/Features/Transactions/Sources/ViewModels/TransactionSceneViewModel.swift +++ b/Features/Transactions/Sources/ViewModels/TransactionSceneViewModel.swift @@ -18,6 +18,10 @@ public final class TransactionSceneViewModel { var request: TransactionRequest var transactionExtended: TransactionExtended + var swapResult: SwapResult? { swapMetadata?.swapResult } + + var isPresentingShareSheet = false + var isPresentingInfoSheet: InfoSheetType? = .none var isPresentingTransactionSheet: TransactionSheetType? public init( @@ -43,7 +47,7 @@ extension TransactionSceneViewModel: ListSectionProvideable { [ ListSection(type: .header, [.header]), ListSection(type: .swapAction, [.swapButton]), - ListSection(type: .details, [.date, .status, .participant, .memo, .network, .pnl, .price, .size, .provider, .fee]), + ListSection(type: .details, detailItems), ListSection(type: .explorer, [.explorerLink]) ] } @@ -53,7 +57,10 @@ extension TransactionSceneViewModel: ListSectionProvideable { case .header: headerViewModel case .swapButton: TransactionSwapButtonViewModel(transaction: transactionExtended) case .date: TransactionDateViewModel(date: model.transaction.transaction.createdAt) - case .status: TransactionStatusViewModel(state: model.transaction.transaction.state, onInfoAction: onSelectStatusInfo) + case .status: + TransactionStatusViewModel(state: model.transaction.transaction.state, onInfoAction: onSelectStatusInfo) + case .swapStatus: + TransactionStatusViewModel(title: model.swapStatusTitle, state: model.transaction.transaction.state, swapResult: swapResult, onInfoAction: onSelectStatusInfo) case .participant: TransactionParticipantViewModel(transactionViewModel: model) case .memo: TransactionMemoViewModel(transaction: model.transaction.transaction) case .network: TransactionNetworkViewModel(chain: model.transaction.asset.chain) @@ -115,7 +122,7 @@ extension TransactionSceneViewModel { currency: preferences.currency ) } - + private var headerViewModel: TransactionHeaderViewModel { TransactionHeaderViewModel( transaction: model.transaction, @@ -130,6 +137,30 @@ extension TransactionSceneViewModel { ) } + private var detailItems: [TransactionItem] { + var items: [TransactionItem] = [.date] + if isCrossChainSwap { + items.append(.swapStatus) + } + + if !isCrossChainSwap { + items.append(.status) + } + + items.append(contentsOf: [.participant, .memo, .network, .pnl, .price, .size, .provider, .fee]) + return items + } + + private var swapMetadata: TransactionSwapMetadata? { + guard case let .swap(metadata) = transactionExtended.transaction.metadata else { return nil } + return metadata + } + + private var isCrossChainSwap: Bool { + guard let metadata = swapMetadata else { return false } + return metadata.fromAsset.chain != metadata.toAsset.chain + } + var feeDetailsViewModel: NetworkFeeSceneViewModel { NetworkFeeSceneViewModel( chain: model.transaction.transaction.assetId.chain, diff --git a/Features/Transactions/Sources/ViewModels/TransactionStatusViewModel.swift b/Features/Transactions/Sources/ViewModels/TransactionStatusViewModel.swift index b00c5fb17..1fbb4638a 100644 --- a/Features/Transactions/Sources/ViewModels/TransactionStatusViewModel.swift +++ b/Features/Transactions/Sources/ViewModels/TransactionStatusViewModel.swift @@ -9,20 +9,67 @@ import Components import Style public struct TransactionStatusViewModel { + private let title: String private let state: TransactionState + private let swapResult: SwapResult? private let onInfoAction: VoidAction public init( + title: String = Localized.Transaction.status, state: TransactionState, + swapResult: SwapResult? = nil, onInfoAction: VoidAction ) { + self.title = title self.state = state + self.swapResult = swapResult self.onInfoAction = onInfoAction } private var stateViewModel: TransactionStateViewModel { TransactionStateViewModel(state: state) } + + private var swapStateViewModel: TransactionStateViewModel? { + guard let swapResult else { return nil } + let mappedState: TransactionState = { + switch swapResult.status { + case .pending: .pending + case .completed: .confirmed + case .failed: .failed + case .refunded: .failed + } + }() + return TransactionStateViewModel(state: mappedState) + } + + private var statusTitle: String { + if let swapResult { + switch swapResult.status { + case .pending: return Localized.Transaction.Status.pending + case .completed: return Localized.Transaction.Status.confirmed + case .failed: return Localized.Transaction.Status.failed + case .refunded: return Localized.Transaction.Status.refunded + } + } + return stateViewModel.title + } + + private var statusColor: Color { + swapStateViewModel?.color ?? stateViewModel.color + } + + private var statusBackground: Color { + swapStateViewModel?.background ?? stateViewModel.background + } + + private var statusTag: TitleTagType { + if swapResult?.status == .pending || (swapResult == nil && state == .pending) { + return .progressView() + } + let image = (swapStateViewModel ?? stateViewModel).stateImage + return .image(image) + } } // MARK: - ItemModelProvidable @@ -30,10 +77,10 @@ public struct TransactionStatusViewModel { extension TransactionStatusViewModel: ItemModelProvidable { public var itemModel: TransactionItemModel { .listItem(ListItemModel( - title: Localized.Transaction.status, - titleTagType: state == .pending ? .progressView() : .image(stateViewModel.stateImage), - subtitle: stateViewModel.title, - subtitleStyle: TextStyle(font: .callout, color: stateViewModel.color), + title: title, + titleTagType: statusTag, + subtitle: statusTitle, + subtitleStyle: TextStyle(font: .callout, color: statusColor, background: statusBackground), infoAction: onInfoAction )) } diff --git a/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionSceneViewModelTests.swift b/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionSceneViewModelTests.swift index bf276af9f..1c6a277f1 100644 --- a/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionSceneViewModelTests.swift +++ b/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionSceneViewModelTests.swift @@ -7,6 +7,7 @@ import PreferencesTestKit import PrimitivesComponents import Style import Components +import ExplorerService @testable import Transactions @testable import Store @@ -92,6 +93,70 @@ struct TransactionSceneViewModelTests { } } + @Test + func swapStatusReflectsSwapResult() { + let swapResult = SwapResult( + status: .completed, + fromChain: .ethereum, + fromTxHash: "0xsource", + toChain: .some(.arbitrum), + toTxHash: "0xdestination" + ) + + let metadata = TransactionMetadata.swap( + TransactionSwapMetadata( + fromAsset: AssetId(chain: .ethereum, tokenId: nil), + fromValue: "1", + toAsset: AssetId(chain: .arbitrum, tokenId: nil), + toValue: "1", + provider: "across", + swapResult: swapResult + ) + ) + + let swapModel = TransactionSceneViewModel.mock( + type: .swap, + metadata: metadata + ) + + if case .listItem(let item) = swapModel.item(for: TransactionItem.swapStatus) { + #expect(item.title == Localized.Transaction.swapStatus) + #expect(item.subtitle == Localized.Transaction.Status.confirmed) + } else { + Issue.record("Expected swap status list item") + } + + } + + @Test + func swapStatusLoadsPersistedResult() { + let expected = SwapResult( + status: .completed, + fromChain: .ethereum, + fromTxHash: "0xsource", + toChain: .some(.arbitrum), + toTxHash: "0xdestination" + ) + + let metadata = TransactionMetadata.swap( + TransactionSwapMetadata( + fromAsset: .mock(.ethereum), + fromValue: "1", + toAsset: .mock(.arbitrum), + toValue: "1", + provider: "across", + swapResult: expected + ) + ) + + let model = TransactionSceneViewModel.mock( + type: .swap, + metadata: metadata + ) + + #expect(model.swapResult == expected) + } + @Test func participantItemModel() { let transaction = TransactionExtended.mock( @@ -105,7 +170,8 @@ struct TransactionSceneViewModelTests { let modelWithAddresses = TransactionSceneViewModel( transaction: transaction, walletId: "test_wallet_id", - preferences: Preferences.standard + preferences: Preferences.standard, + explorerService: ExplorerService.standard ) if case .participant(let item) = modelWithAddresses.item(for: TransactionItem.participant) { @@ -170,11 +236,11 @@ struct TransactionSceneViewModelTests { func feeItemModel() { let model = TransactionSceneViewModel.mock() - if case .listItem(let item) = model.item(for: TransactionItem.fee) { + if case .fee(let item) = model.item(for: TransactionItem.fee) { #expect(item.title == Localized.Transfer.networkFee) #expect(item.infoAction != nil) } else { - Issue.record("Expected listItem for fee") + Issue.record("Expected fee item for network fee") } } @@ -226,26 +292,30 @@ struct TransactionSceneViewModelTests { } extension TransactionSceneViewModel { - static func mock( + fileprivate static func mock( type: TransactionType = .transfer, state: TransactionState = .confirmed, direction: TransactionDirection = .outgoing, assetId: AssetId = .mock(), toAddress: String = "participant_address", memo: String? = nil, - createdAt: Date = Date() + createdAt: Date = Date(), + metadata: TransactionMetadata? = nil ) -> TransactionSceneViewModel { - TransactionSceneViewModel( - transaction: TransactionExtended.mock( - transaction: Transaction.mock( - type: type, - state: state, - direction: direction, - assetId: assetId, - to: toAddress, - memo: memo - ) - ), + let transactionExtended = TransactionExtended.mock( + transaction: Transaction.mock( + type: type, + state: state, + direction: direction, + assetId: assetId, + to: toAddress, + memo: memo, + metadata: metadata + ) + ) + + return TransactionSceneViewModel( + transaction: transactionExtended, walletId: "test_wallet_id", preferences: Preferences.standard ) diff --git a/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionViewModelTests.swift b/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionViewModelTests.swift index 4d812c54b..53358820d 100644 --- a/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionViewModelTests.swift +++ b/Features/Transactions/Tests/TransactionsTests/ViewModels/TransactionViewModelTests.swift @@ -70,6 +70,26 @@ final class TransactionViewModelTests { #expect(unknownViewModel.titleExtraTextValue?.text.contains("5678") == true) } + @Test + func swapTransactionsUseProviderSpecificExplorer() { + let model = TransactionViewModel.mock( + participant: "recipient.near", + swapProvider: SwapProvider.nearIntents.rawValue + ) + + #expect(model.transactionExplorerUrl.absoluteString.contains("recipient.near")) + } + + @Test + func swapIdentifierUsesRecipientForNearIntents() { + let model = TransactionViewModel.mock( + participant: "recipient.near", + swapProvider: SwapProvider.nearIntents.rawValue + ) + + #expect(model.transactionExplorerUrl.absoluteString.contains("recipient.near")) + } + func testTransactionTitle(expectedTitle: String, transaction: Transaction) { #expect(TransactionViewModel(explorerService: MockExplorerLink(), transaction: .mock(transaction: transaction), currency: "USD").titleTextValue.text == expectedTitle) } @@ -85,7 +105,9 @@ extension TransactionViewModel { participant: String = "", memo: String? = nil, fromAddress: AddressName? = nil, - toAddress: AddressName? = nil + toAddress: AddressName? = nil, + swapProvider: String? = nil, + swapResult: SwapResult? = nil ) -> TransactionViewModel { let fromAsset = Asset.mockEthereum() let toAsset = Asset.mockEthereumUSDT() @@ -96,7 +118,8 @@ extension TransactionViewModel { fromValue: fromValue, toAsset: toAsset.id, toValue: toValue, - provider: "" + provider: swapProvider, + swapResult: swapResult ) ) diff --git a/Gem/Services/ServicesFactory.swift b/Gem/Services/ServicesFactory.swift index 704918324..18abd7eca 100644 --- a/Gem/Services/ServicesFactory.swift +++ b/Gem/Services/ServicesFactory.swift @@ -83,13 +83,16 @@ struct ServicesFactory { nftStore: storeManager.nftStore, deviceService: deviceService ) + let swapTransactionService = SwapTransactionService(nodeProvider: nodeService) let transactionsService = Self.makeTransactionsService( transactionStore: storeManager.transactionStore, assetsService: assetsService, walletStore: storeManager.walletStore, deviceService: deviceService, - addressStore: storeManager.addressStore + addressStore: storeManager.addressStore, + swapTransactionService: swapTransactionService ) + transactionsService.monitorPendingSwapStatuses() let transactionService = Self.makeTransactionService( transactionStore: storeManager.transactionStore, stakeService: stakeService, @@ -325,14 +328,16 @@ extension ServicesFactory { assetsService: AssetsService, walletStore: WalletStore, deviceService: any DeviceServiceable, - addressStore: AddressStore + addressStore: AddressStore, + swapTransactionService: SwapTransactionService ) -> TransactionsService { TransactionsService( transactionStore: transactionStore, assetsService: assetsService, walletStore: walletStore, deviceService: deviceService, - addressStore: addressStore + addressStore: addressStore, + swapTransactionService: swapTransactionService ) } diff --git a/Packages/ChainServices/ExplorerService/ExplorerService.swift b/Packages/ChainServices/ExplorerService/ExplorerService.swift index ae343ebc9..4ca5566d7 100644 --- a/Packages/ChainServices/ExplorerService/ExplorerService.swift +++ b/Packages/ChainServices/ExplorerService/ExplorerService.swift @@ -26,20 +26,44 @@ public struct ExplorerService { Gemstone.Config.shared.getBlockExplorers(chain: chain.id) } - public func transactionUrl(chain: Chain, hash: String, swapProvider: String?) -> BlockExplorerLink { + public func transactionUrl(chain: Chain, hash: String) -> BlockExplorerLink { let name = explorerNameOrDefault(chain: chain) let explorer = Gemstone.Explorer(chain: chain.id) - if let swapProvider, let url = explorer.getTransactionSwapUrl( - explorerName: name, - transactionId: hash, - providerId: swapProvider - ) { - return BlockExplorerLink(name: url.name, link: url.url) - } let url = URL(string: explorer.getTransactionUrl(explorerName: name, transactionId: hash))! return BlockExplorerLink(name: name, link: url.absoluteString) } + public func swapIdentifier( + chain: Chain, + provider: String, + hash: String, + recipient: String? = nil + ) -> String { + switch provider.lowercased() { + case SwapProvider.nearIntents.rawValue.lowercased(): + if let recipient = recipient?.trimmingCharacters(in: .whitespacesAndNewlines), !recipient.isEmpty { + return recipient + } + return hash + default: + return hash + } + } + + public func swapTransactionUrl(chain: Chain, provider: String, identifier: String) -> BlockExplorerLink? { + let name = explorerNameOrDefault(chain: chain) + let explorer = Gemstone.Explorer(chain: chain.id) + guard let url = explorer.getTransactionSwapUrl( + explorerName: name, + transactionId: identifier, + providerId: provider + ) else { + return nil + } + + return BlockExplorerLink(name: url.name, link: url.url) + } + public func addressUrl(chain: Chain, address: String) -> BlockExplorerLink { let name = explorerNameOrDefault(chain: chain) let explorer = Gemstone.Explorer(chain: chain.id) diff --git a/Packages/FeatureServices/Package.swift b/Packages/FeatureServices/Package.swift index 2aaedcb5c..559997b18 100644 --- a/Packages/FeatureServices/Package.swift +++ b/Packages/FeatureServices/Package.swift @@ -227,6 +227,7 @@ let package = Package( "Preferences", "AssetsService", "DeviceService", + "SwapService" ], path: "TransactionsService", exclude: ["TestKit"] @@ -238,6 +239,7 @@ let package = Package( "AssetsServiceTestKit", "TransactionsService", "DeviceServiceTestKit", + "SwapService" ], path: "TransactionsService/TestKit" ), diff --git a/Packages/FeatureServices/SwapService/SwapService.swift b/Packages/FeatureServices/SwapService/SwapService.swift index 72d73004a..2e5650f49 100644 --- a/Packages/FeatureServices/SwapService/SwapService.swift +++ b/Packages/FeatureServices/SwapService/SwapService.swift @@ -17,7 +17,6 @@ import func Gemstone.getDefaultSlippage import GemstonePrimitives import NativeProviderService import Primitives -import enum Primitives.AnyError import enum Primitives.Chain import enum Primitives.EVMChain diff --git a/Packages/FeatureServices/SwapService/SwapTransactionService.swift b/Packages/FeatureServices/SwapService/SwapTransactionService.swift index 1eb104cf5..66c08d296 100644 --- a/Packages/FeatureServices/SwapService/SwapTransactionService.swift +++ b/Packages/FeatureServices/SwapService/SwapTransactionService.swift @@ -2,34 +2,52 @@ import Foundation import class Gemstone.GemSwapper +import enum Gemstone.SwapperProvider +import struct Gemstone.SwapperSwapResult +import class Gemstone.SwapProviderConfig import GemstonePrimitives -import ChainService import NativeProviderService import Primitives -public struct SwapTransactionService: Sendable { - private let nodeProvider: any NodeURLFetchable +public protocol SwapStatusProviding: Sendable { + func getSwapResult( + providerId: SwapProvider, + chain: Primitives.Chain, + transactionId: String, + recipient: String + ) async throws -> SwapResult +} + +public struct SwapTransactionService: SwapStatusProviding, Sendable { private let swapper: GemSwapper - private let swapConfig = GemstoneConfig.shared.getSwapConfig() - - public init( - nodeProvider: any NodeURLFetchable - ) { - self.nodeProvider = nodeProvider - self.swapper = GemSwapper( - rpcProvider: NativeProvider(nodeProvider: nodeProvider) - ) + + public init(nodeProvider: any NodeURLFetchable) { + swapper = GemSwapper(rpcProvider: NativeProvider(nodeProvider: nodeProvider)) } - - public func getTransactionStatus(chain: Primitives.Chain, provider: String?, hash: String) async throws { - //let provider = SwapProviderConfig - - //let provider = SwapProvider.across - -// let status = try await swapper.getTransactionStatus( -// chain: chain.rawValue, -// swapProvider: .across, -// transactionHash: hash -// ) + + func shouldUpdate(id: SwapProvider) -> Bool { + let providerConfig = SwapProviderConfig.fromString(id: id.rawValue) + return providerConfig.inner().mode != .onChain + } + + public func getSwapResult( + providerId: SwapProvider, + chain: Primitives.Chain, + transactionId: String, + recipient: String + ) async throws -> SwapResult { + let swapProvider = SwapProviderConfig.fromString(id: providerId.rawValue).inner().id + let transactionHash = if swapProvider == .nearIntents { + recipient + } else { + transactionId + } + let result = try await swapper.getSwapResult( + chain: chain.rawValue, + swapProvider: swapProvider, + transactionHash: transactionHash + ) + + return try result.asPrimitives() } } diff --git a/Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderType+TestKit.swift b/Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderType+TestKit.swift index 620c518f2..f86f21005 100644 --- a/Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderType+TestKit.swift +++ b/Packages/FeatureServices/SwapService/TestKit/Types+Mock/SwapperProviderType+TestKit.swift @@ -10,7 +10,8 @@ public extension SwapperProviderType { id: .pancakeswapV3, name: "PancakeSwap", protocol: "v3", - protocolId: "pancakeswap_v3" + protocolId: "pancakeswap_v3", + mode: .onChain ) } } diff --git a/Packages/FeatureServices/TransactionsService/TestKit/TransactionsService+TestKit.swift b/Packages/FeatureServices/TransactionsService/TestKit/TransactionsService+TestKit.swift index a9141a630..a6168bb40 100644 --- a/Packages/FeatureServices/TransactionsService/TestKit/TransactionsService+TestKit.swift +++ b/Packages/FeatureServices/TransactionsService/TestKit/TransactionsService+TestKit.swift @@ -5,6 +5,8 @@ import TransactionsService import StoreTestKit import AssetsServiceTestKit import DeviceServiceTestKit +import SwapService +import Primitives public extension TransactionsService { static func mock() -> TransactionsService { @@ -13,7 +15,19 @@ public extension TransactionsService { assetsService: .mock(), walletStore: .mock(), deviceService: DeviceServiceMock(), - addressStore: .mock() + addressStore: .mock(), + swapTransactionService: SwapStatusProvidingMock() ) } } + +private struct SwapStatusProvidingMock: SwapStatusProviding { + func getSwapResult( + providerId: SwapProvider, + chain: Chain, + transactionId: String, + recipient: String + ) async throws -> SwapResult { + SwapResult(status: .pending, fromChain: chain, fromTxHash: transactionId, toChain: nil, toTxHash: nil) + } +} diff --git a/Packages/FeatureServices/TransactionsService/TransactionsService.swift b/Packages/FeatureServices/TransactionsService/TransactionsService.swift index a2dff98fb..a1db0bb90 100644 --- a/Packages/FeatureServices/TransactionsService/TransactionsService.swift +++ b/Packages/FeatureServices/TransactionsService/TransactionsService.swift @@ -7,6 +7,7 @@ import Store import Preferences import AssetsService import DeviceService +import SwapService public final class TransactionsService: Sendable { let provider: any GemAPITransactionService @@ -15,6 +16,8 @@ public final class TransactionsService: Sendable { let walletStore: WalletStore private let deviceService: any DeviceServiceable private let addressStore: AddressStore + private let swapTransactionService: any SwapStatusProviding + private let swapStatusTasks = SwapStatusTaskManager() public init( provider: any GemAPITransactionService = GemAPIService(), @@ -22,7 +25,8 @@ public final class TransactionsService: Sendable { assetsService: AssetsService, walletStore: WalletStore, deviceService: any DeviceServiceable, - addressStore: AddressStore + addressStore: AddressStore, + swapTransactionService: any SwapStatusProviding ) { self.provider = provider self.transactionStore = transactionStore @@ -30,6 +34,7 @@ public final class TransactionsService: Sendable { self.walletStore = walletStore self.deviceService = deviceService self.addressStore = addressStore + self.swapTransactionService = swapTransactionService } public func updateAll(walletId: WalletId) async throws { @@ -51,6 +56,7 @@ public final class TransactionsService: Sendable { try addressStore.addAddressNames(response.addressNames) store.transactionsTimestamp = newTimestamp + monitorSwapStatuses(for: response.transactions) } public func updateForAsset(wallet: Wallet, assetId: AssetId) async throws { @@ -72,16 +78,44 @@ public final class TransactionsService: Sendable { try addressStore.addAddressNames(response.addressNames) store.setTransactionsForAssetTimestamp(assetId: assetId.identifier, value: newTimestamp) + monitorSwapStatuses(for: response.transactions) } public func addTransaction(walletId: WalletId, transaction: Transaction) throws { try transactionStore.addTransactions(walletId: walletId.id, transactions: [transaction]) + monitorSwapStatuses(for: [transaction]) + } + + public func updateSwapResult(transactionId: String, swapResult: SwapResult) throws { + let record = try transactionStore.getTransactionRecord(transactionId: transactionId) + guard let metadata = record.metadata else { return } + + guard case let .swap(swapMetadata) = metadata else { + return + } + + let updatedMetadata = TransactionMetadata.swap( + TransactionSwapMetadata( + fromAsset: swapMetadata.fromAsset, + fromValue: swapMetadata.fromValue, + toAsset: swapMetadata.toAsset, + toValue: swapMetadata.toValue, + provider: swapMetadata.provider, + swapResult: swapResult + ) + ) + try transactionStore.updateMetadata(transactionId: transactionId, metadata: updatedMetadata) } public func getTransaction(walletId: WalletId, transactionId: String) throws -> TransactionExtended { try transactionStore.getTransaction(walletId: walletId.id, transactionId: transactionId) } + public func monitorPendingSwapStatuses() { + guard let pending = try? transactionStore.getSwapTransactionsNeedingStatusUpdate() else { return } + monitorSwapStatuses(for: pending.map(\.transaction)) + } + private func prefetchAssets(walletId: WalletId, transactions: [Transaction]) async throws { let assetIds = transactions.map { $0.assetIds }.flatMap { $0 } if assetIds.isEmpty { @@ -90,4 +124,100 @@ public final class TransactionsService: Sendable { let newAssets = try await assetsService.prefetchAssets(assetIds: assetIds) try assetsService.addBalancesIfMissing(walletId: walletId, assetIds: newAssets) } + + private func monitorSwapStatuses(for transactions: [Transaction]) { + guard !transactions.isEmpty else { return } + + Task { [weak self] in + guard let self else { return } + for transaction in transactions { + guard let context = self.makeSwapStatusContext(for: transaction) else { continue } + await self.swapStatusTasks.enqueue(id: transaction.id) { + await self.trackSwapStatus(using: context, transactionId: transaction.id) + } + } + } + } + + private func trackSwapStatus(using context: SwapStatusContext, transactionId: String) async { + var backoff: Duration = .seconds(5) + + while !Task.isCancelled { + do { + let result = try await swapTransactionService.getSwapResult( + providerId: context.provider, + chain: context.chain, + transactionId: context.transactionId, + recipient: context.recipient + ) + + do { + try updateSwapResult(transactionId: transactionId, swapResult: result) + } catch { + NSLog("TransactionsService update swap result error: \(error)") + } + + if result.status != .pending { + break + } + + backoff = .seconds(5) + } catch { + NSLog("TransactionsService swap status \(context) error: \(error)") + try? await Task.sleep(for: backoff) + backoff = min(backoff * 2, .seconds(300)) + continue + } + + try? await Task.sleep(for: .seconds(30)) + } + } + + private func makeSwapStatusContext(for transaction: Transaction) -> SwapStatusContext? { + guard case let .swap(metadata) = transaction.metadata else { return nil } + guard + let provider = metadata.provider, + let swapProvider = SwapProvider(rawValue: provider) + else { + return nil + } + + if metadata.fromAsset.chain == metadata.toAsset.chain { + return nil + } + + if let swapResult = metadata.swapResult, swapResult.status != .pending { + return nil + } + + return SwapStatusContext( + provider: swapProvider, + chain: transaction.assetId.chain, + transactionId: transaction.hash, + recipient: transaction.to + ) + } +} + +private struct SwapStatusContext { + let provider: SwapProvider + let chain: Chain + let transactionId: String + let recipient: String +} + +private actor SwapStatusTaskManager { + private var tasks: [String: Task] = [:] + + func enqueue(id: String, operation: @escaping () async -> Void) { + guard tasks[id] == nil else { return } + tasks[id] = Task { + await operation() + self.removeTask(id: id) + } + } + + private func removeTask(id: String) { + tasks[id] = nil + } } diff --git a/Packages/GemstonePrimitives/Sources/Extensions/SwapperSwapResult+GemstonePrimitives.swift b/Packages/GemstonePrimitives/Sources/Extensions/SwapperSwapResult+GemstonePrimitives.swift new file mode 100644 index 000000000..391344298 --- /dev/null +++ b/Packages/GemstonePrimitives/Sources/Extensions/SwapperSwapResult+GemstonePrimitives.swift @@ -0,0 +1,40 @@ +// Copyright (c). Gem Wallet. All rights reserved. + +import Foundation +import struct Gemstone.SwapperSwapResult +import enum Gemstone.SwapperSwapStatus +import Primitives + +public extension SwapperSwapResult { + func asPrimitives() throws -> SwapResult { + guard let primitiveFromChain = Chain(rawValue: fromChain) else { + throw AnyError("Invalid swap result chain: \(fromChain)") + } + + let destinationChain = try toChain.map { chainId -> Chain in + guard let chain = Chain(rawValue: chainId) else { + throw AnyError("Invalid destination chain: \(chainId)") + } + return chain + } + + return SwapResult( + status: SwapStatus(status), + fromChain: primitiveFromChain, + fromTxHash: fromTxHash, + toChain: destinationChain, + toTxHash: toTxHash + ) + } +} + +private extension SwapStatus { + init(_ status: SwapperSwapStatus) { + switch status { + case .pending: self = .pending + case .completed: self = .completed + case .failed: self = .failed + case .refunded: self = .refunded + } + } +} diff --git a/Packages/Localization/Sources/Localized.swift b/Packages/Localization/Sources/Localized.swift index b8149eefd..504f0d786 100644 --- a/Packages/Localization/Sources/Localized.swift +++ b/Packages/Localization/Sources/Localized.swift @@ -1130,6 +1130,8 @@ public enum Localized { public static let status = Localized.tr("Localizable", "transaction.status", fallback: "Status") /// Swap Again public static let swapAgain = Localized.tr("Localizable", "transaction.swap_again", fallback: "Swap Again") + /// Swap Status + public static let swapStatus = Localized.tr("Localizable", "transaction.swap_status", fallback: "Swap Status") /// View on %@ public static func viewOn(_ p1: Any) -> String { return Localized.tr("Localizable", "transaction.view_on", String(describing: p1), fallback: "View on %@") @@ -1143,6 +1145,8 @@ public enum Localized { public static let pending = Localized.tr("Localizable", "transaction.status.pending", fallback: "Pending") /// Reverted public static let reverted = Localized.tr("Localizable", "transaction.status.reverted", fallback: "Reverted") + /// Refunded + public static let refunded = Localized.tr("Localizable", "transaction.status.refunded", fallback: "Refunded") } public enum Title { /// Received diff --git a/Packages/Primitives/Sources/Protocols/ExplorerAdressFetchable.swift b/Packages/Primitives/Sources/Protocols/ExplorerAdressFetchable.swift index 8f5b2dda2..62d6bf5f4 100644 --- a/Packages/Primitives/Sources/Protocols/ExplorerAdressFetchable.swift +++ b/Packages/Primitives/Sources/Protocols/ExplorerAdressFetchable.swift @@ -4,5 +4,45 @@ import Foundation public protocol ExplorerLinkFetchable: Sendable { func addressUrl(chain: Chain, address: String) -> BlockExplorerLink - func transactionUrl(chain: Chain, hash: String, swapProvider: String?) -> BlockExplorerLink + func transactionUrl(chain: Chain, hash: String) -> BlockExplorerLink + func swapTransactionUrl(chain: Chain, provider: String, identifier: String) -> BlockExplorerLink? + func swapIdentifier(chain: Chain, provider: String, hash: String, recipient: String?) -> String +} + +public extension ExplorerLinkFetchable { + func swapIdentifier( + chain: Chain, + provider: String, + hash: String, + recipient: String? + ) -> String { + hash + } + + func transactionLink( + chain: Chain, + provider: String?, + hash: String, + recipient: String? + ) -> BlockExplorerLink { + guard + let provider, + !provider.isEmpty + else { + return transactionUrl(chain: chain, hash: hash) + } + + let identifier = swapIdentifier( + chain: chain, + provider: provider, + hash: hash, + recipient: recipient + ) + + if let link = swapTransactionUrl(chain: chain, provider: provider, identifier: identifier) { + return link + } + + return transactionUrl(chain: chain, hash: hash) + } } diff --git a/Packages/Primitives/Sources/TransactionMetadataTypes.swift b/Packages/Primitives/Sources/TransactionMetadataTypes.swift index 79087a1df..888bc2e7d 100644 --- a/Packages/Primitives/Sources/TransactionMetadataTypes.swift +++ b/Packages/Primitives/Sources/TransactionMetadataTypes.swift @@ -34,12 +34,14 @@ public struct TransactionSwapMetadata: Codable, Sendable { public let toAsset: AssetId public let toValue: String public let provider: String? + public let swapResult: SwapResult? - public init(fromAsset: AssetId, fromValue: String, toAsset: AssetId, toValue: String, provider: String?) { + public init(fromAsset: AssetId, fromValue: String, toAsset: AssetId, toValue: String, provider: String?, swapResult: SwapResult?) { self.fromAsset = fromAsset self.fromValue = fromValue self.toAsset = toAsset self.toValue = toValue self.provider = provider + self.swapResult = swapResult } } diff --git a/Packages/Primitives/Sources/TransferDataType.swift b/Packages/Primitives/Sources/TransferDataType.swift index 19b355a7e..6a7cab660 100644 --- a/Packages/Primitives/Sources/TransferDataType.swift +++ b/Packages/Primitives/Sources/TransferDataType.swift @@ -69,7 +69,8 @@ public enum TransferDataType: Hashable, Equatable, Sendable { fromValue: data.quote.fromValue, toAsset: toAsset.id, toValue: data.quote.toValue, - provider: data.quote.providerData.provider.rawValue + provider: data.quote.providerData.provider.rawValue, + swapResult: nil ) ) case .transferNft(let asset): diff --git a/Packages/Primitives/TestKit/ExplorerLinkFetchable+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/ExplorerLinkFetchable+PrimitivesTestKit.swift index a779cfe55..7841c7843 100644 --- a/Packages/Primitives/TestKit/ExplorerLinkFetchable+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/ExplorerLinkFetchable+PrimitivesTestKit.swift @@ -13,10 +13,19 @@ public struct MockExplorerLink: ExplorerLinkFetchable { ) } - public func transactionUrl(chain: Chain, hash: String, swapProvider: String? = .none) -> BlockExplorerLink { + public func transactionUrl(chain: Chain, hash: String) -> BlockExplorerLink { BlockExplorerLink( name: "MockExplorer", link: "https://mock.explorer/\(chain.rawValue)/tx/\(hash)" ) } + + public func swapTransactionUrl(chain: Chain, provider: String, identifier: String) -> BlockExplorerLink? { + BlockExplorerLink( + name: "MockExplorer", + link: "https://mock.explorer/\(chain.rawValue)/swap/\(provider)/\(identifier)" + ) + } + + public func swapIdentifier(chain: Chain, provider: String, hash: String, recipient: String?) -> String { recipient ?? hash } } diff --git a/Packages/Primitives/TestKit/TransactionSwapMetadata+PrimitivesTestKit.swift b/Packages/Primitives/TestKit/TransactionSwapMetadata+PrimitivesTestKit.swift index b81cbd4e4..7aad782a2 100644 --- a/Packages/Primitives/TestKit/TransactionSwapMetadata+PrimitivesTestKit.swift +++ b/Packages/Primitives/TestKit/TransactionSwapMetadata+PrimitivesTestKit.swift @@ -9,14 +9,16 @@ public extension TransactionSwapMetadata { fromValue: String = "", toAsset: AssetId = .mock(.smartChain), toValue: String = "", - provider: String? = nil + provider: String? = nil, + swapResult: SwapResult? = nil ) -> TransactionSwapMetadata { TransactionSwapMetadata( fromAsset: fromAsset, fromValue: fromValue, toAsset: toAsset, toValue: toValue, - provider: provider + provider: provider, + swapResult: swapResult ) } } diff --git a/Packages/PrimitivesComponents/Sources/Components/TransactionView.swift b/Packages/PrimitivesComponents/Sources/Components/TransactionView.swift index af8a4b348..cdb408b71 100644 --- a/Packages/PrimitivesComponents/Sources/Components/TransactionView.swift +++ b/Packages/PrimitivesComponents/Sources/Components/TransactionView.swift @@ -36,9 +36,15 @@ private struct ExplorerMock: ExplorerLinkFetchable { func addressUrl(chain: Chain, address: String) -> BlockExplorerLink { .init(name: "", link: "") } - func transactionUrl(chain: Chain, hash: String, swapProvider: String?) -> BlockExplorerLink { + func transactionUrl(chain: Chain, hash: String) -> BlockExplorerLink { .init(name: "", link: "") } + + func swapTransactionUrl(chain: Chain, provider: String, identifier: String) -> BlockExplorerLink? { + .init(name: "", link: "") + } + + func swapIdentifier(chain: Chain, provider: String, hash: String, recipient: String?) -> String { hash } } #Preview { diff --git a/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift b/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift index 5eecc635c..6373cf291 100644 --- a/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift +++ b/Packages/PrimitivesComponents/Sources/Types/TransactionHeaderBuilder.swift @@ -86,7 +86,8 @@ public struct TransactionHeaderTypeBuilder { fromValue: data.quote.fromValue, toAsset: toAsset.id, toValue: data.quote.toValue, - provider: data.quote.providerData.provider.rawValue + provider: data.quote.providerData.provider.rawValue, + swapResult: nil )) ) ) diff --git a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift index 8736ad52c..334c05acf 100644 --- a/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift +++ b/Packages/PrimitivesComponents/Sources/ViewModels/TransactionViewModel.swift @@ -150,6 +150,10 @@ public struct TransactionViewModel: Sendable { } } + public var swapStatusTitle: String { + Localized.Transaction.swapStatus + } + public var titleTagTextValue: TextValue? { let title: String? = switch transaction.transaction.state { case .confirmed: .none @@ -324,17 +328,21 @@ public struct TransactionViewModel: Sendable { } private var transactionLink: BlockExplorerLink { - let swapProvider: String? = switch transaction.transaction.metadata { - case .swap(let metadata): metadata.provider - default: .none - } - return explorerService.transactionUrl( + explorerService.transactionLink( chain: assetId.chain, + provider: transaction.transaction.swapProvider, hash: transaction.transaction.hash, - swapProvider: swapProvider + recipient: transaction.transaction.to ) } private var addressLink: BlockExplorerLink { explorerService.addressUrl(chain: assetId.chain, address: participant) } private var assetId: AssetId { transaction.transaction.assetId } } + +public extension Primitives.Transaction { + var swapProvider: String? { + guard case let .swap(metadata) = metadata else { return nil } + return metadata.provider + } +} diff --git a/Packages/Store/Sources/Stores/TransactionStore.swift b/Packages/Store/Sources/Stores/TransactionStore.swift index 289d4edae..9b8ad1d21 100644 --- a/Packages/Store/Sources/Stores/TransactionStore.swift +++ b/Packages/Store/Sources/Stores/TransactionStore.swift @@ -34,6 +34,18 @@ public struct TransactionStore: Sendable { } } + public func getSwapTransactionsNeedingStatusUpdate() throws -> [TransactionWallet] { + try db.read { db in + try TransactionRecord + .including(required: TransactionRecord.wallet) + .filter(TransactionRecord.Columns.type == TransactionType.swap.rawValue) + .asRequest(of: WalletTransactionInfo.self) + .fetchAll(db) + .map(\.transactionWallet) + .filter(\.needsSwapStatusUpdate) + } + } + public func getTransactionRecord(transactionId: String) throws -> TransactionRecord { try db.read { db in guard let transaction = try TransactionRecord @@ -161,3 +173,17 @@ public struct TransactionStore: Sendable { } } } + +private extension TransactionWallet { + var needsSwapStatusUpdate: Bool { + guard case let .swap(metadata) = transaction.metadata else { return false } + guard let provider = metadata.provider, !provider.isEmpty else { return false } + if metadata.fromAsset.chain == metadata.toAsset.chain { + return false + } + if let swapResult = metadata.swapResult, swapResult.status != .pending { + return false + } + return true + } +} diff --git a/Packages/Store/Tests/StoreTests/TransactionStoreTests.swift b/Packages/Store/Tests/StoreTests/TransactionStoreTests.swift index 81f372b5d..87548f452 100644 --- a/Packages/Store/Tests/StoreTests/TransactionStoreTests.swift +++ b/Packages/Store/Tests/StoreTests/TransactionStoreTests.swift @@ -34,7 +34,7 @@ struct TransactionStoreTests { type: .swap, assetId: btc, metadata: .swap(TransactionSwapMetadata( - fromAsset: btc, fromValue: "100", toAsset: eth, toValue: "200", provider: nil + fromAsset: btc, fromValue: "100", toAsset: eth, toValue: "200", provider: nil, swapResult: nil )) ) ]) @@ -45,7 +45,7 @@ struct TransactionStoreTests { type: .swap, assetId: btc, metadata: .swap(TransactionSwapMetadata( - fromAsset: btc, fromValue: "100", toAsset: sol, toValue: "300", provider: nil + fromAsset: btc, fromValue: "100", toAsset: sol, toValue: "300", provider: nil, swapResult: nil )) ) ]) diff --git a/core b/core index f6df16f40..4502dd274 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f6df16f40d5a3e441d87e66404006e5fdcf60f7e +Subproject commit 4502dd2749516b1c16f637c40c4fcfbb7f855ad8