diff --git a/CHANGELOG.md b/CHANGELOG.md index 689890fe..30b24ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Added + +### [#452] TX Resubmission-the wallet has to periodically resubmit unmined transactions +The Compact block processor's state machine has been extended to check whether there are any unmined and unexpired transactions, and it attempts to resubmit such transactions every 5 minutes. + # 2.1.10 - 2024-06-14 ## Fixed diff --git a/Example/ZcashLightClientSample/ZcashLightClientSample/DemoAppConfig.swift b/Example/ZcashLightClientSample/ZcashLightClientSample/DemoAppConfig.swift index b7a68134..9add4e13 100644 --- a/Example/ZcashLightClientSample/ZcashLightClientSample/DemoAppConfig.swift +++ b/Example/ZcashLightClientSample/ZcashLightClientSample/DemoAppConfig.swift @@ -18,8 +18,8 @@ enum DemoAppConfig { let seed: [UInt8] } - static let host = ZcashSDK.isMainnet ? "mainnet.lightwalletd.com" : "lightwalletd.testnet.electriccoin.co" - static let port: Int = 9067 + static let host = ZcashSDK.isMainnet ? "zec.rocks" : "lightwalletd.testnet.electriccoin.co" + static let port: Int = 443 static let defaultBirthdayHeight: BlockHeight = ZcashSDK.isMainnet ? 935000 : 1386000 diff --git a/Example/ZcashLightClientSample/ZcashLightClientSample/Send/SendViewController.swift b/Example/ZcashLightClientSample/ZcashLightClientSample/Send/SendViewController.swift index 40b04070..58e82fd7 100644 --- a/Example/ZcashLightClientSample/ZcashLightClientSample/Send/SendViewController.swift +++ b/Example/ZcashLightClientSample/ZcashLightClientSample/Send/SendViewController.swift @@ -247,6 +247,7 @@ class SendViewController: UIViewController { KRProgressHUD.dismiss() loggerProxy.info("transaction created: \(pendingTransaction)") } catch { + KRProgressHUD.dismiss() fail(error) loggerProxy.error("SEND FAILED: \(error)") } diff --git a/Sources/ZcashLightClientKit/Block/Actions/Action.swift b/Sources/ZcashLightClientKit/Block/Actions/Action.swift index e41b13fd..cc68117c 100644 --- a/Sources/ZcashLightClientKit/Block/Actions/Action.swift +++ b/Sources/ZcashLightClientKit/Block/Actions/Action.swift @@ -73,6 +73,7 @@ enum CBPState: CaseIterable { case fetchUTXO case handleSaplingParams case clearCache + case txResubmission case finished case failed case stopped diff --git a/Sources/ZcashLightClientKit/Block/Actions/EnhanceAction.swift b/Sources/ZcashLightClientKit/Block/Actions/EnhanceAction.swift index 32ecdb7a..5a5c7397 100644 --- a/Sources/ZcashLightClientKit/Block/Actions/EnhanceAction.swift +++ b/Sources/ZcashLightClientKit/Block/Actions/EnhanceAction.swift @@ -28,7 +28,7 @@ final class EnhanceAction { if lastScannedHeight >= latestBlockHeight { await context.update(state: .clearCache) } else { - await context.update(state: .updateChainTip) + await context.update(state: .txResubmission) } return context diff --git a/Sources/ZcashLightClientKit/Block/Actions/ProcessSuggestedScanRangesAction.swift b/Sources/ZcashLightClientKit/Block/Actions/ProcessSuggestedScanRangesAction.swift index 603f9a04..07962c81 100644 --- a/Sources/ZcashLightClientKit/Block/Actions/ProcessSuggestedScanRangesAction.swift +++ b/Sources/ZcashLightClientKit/Block/Actions/ProcessSuggestedScanRangesAction.swift @@ -59,7 +59,7 @@ extension ProcessSuggestedScanRangesAction: Action { await context.update(state: .download) } else { - await context.update(state: .finished) + await context.update(state: .txResubmission) } return context diff --git a/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift new file mode 100644 index 00000000..424d5fb9 --- /dev/null +++ b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift @@ -0,0 +1,74 @@ +// +// TxResubmissionAction.swift +// +// +// Created by Lukas Korba on 06-17-2024. +// + +import Foundation + +final class TxResubmissionAction { + private enum Constants { + static let thresholdToTrigger = TimeInterval(300.0) + } + + var latestResolvedTime: TimeInterval = 0 + let transactionRepository: TransactionRepository + let transactionEncoder: TransactionEncoder + let logger: Logger + + init(container: DIContainer) { + transactionRepository = container.resolve(TransactionRepository.self) + transactionEncoder = container.resolve(TransactionEncoder.self) + logger = container.resolve(Logger.self) + } +} + +extension TxResubmissionAction: Action { + var removeBlocksCacheWhenFailed: Bool { true } + + func run(with context: ActionContext, didUpdate: @escaping (CompactBlockProcessor.Event) async -> Void) async throws -> ActionContext { + let latestBlockHeight = await context.syncControlData.latestBlockHeight + + // find all candidates for the resubmission + do { + let transactions = try await transactionRepository.findForResubmission(upTo: latestBlockHeight) + + // no candidates, update the time and continue with the next action + if transactions.isEmpty { + latestResolvedTime = Date().timeIntervalSince1970 + } else { + let now = Date().timeIntervalSince1970 + let diff = now - latestResolvedTime + + // the last time resubmission was triggered is more than 5 minutes ago so try again + if diff > Constants.thresholdToTrigger { + // resubmission + do { + for transaction in transactions { + let encodedTransaction = try transaction.encodedTransaction() + + try await transactionEncoder.submit(transaction: encodedTransaction) + logger.info("TxResubmissionAction trying to resubmit transaction") + } + } catch { + logger.error("TxResubmissionAction failed to resubmit candidates") + } + + latestResolvedTime = Date().timeIntervalSince1970 + } + } + } catch { + logger.error("TxResubmissionAction failed to find candidates") + } + + if await context.prevState == .enhance { + await context.update(state: .updateChainTip) + } else { + await context.update(state: .finished) + } + return context + } + + func stop() async { } +} diff --git a/Sources/ZcashLightClientKit/Block/CompactBlockProcessor.swift b/Sources/ZcashLightClientKit/Block/CompactBlockProcessor.swift index 4fc6d056..e3e4d0fa 100644 --- a/Sources/ZcashLightClientKit/Block/CompactBlockProcessor.swift +++ b/Sources/ZcashLightClientKit/Block/CompactBlockProcessor.swift @@ -228,6 +228,8 @@ actor CompactBlockProcessor { action = SaplingParamsAction(container: container) case .clearCache: action = ClearCacheAction(container: container) + case .txResubmission: + action = TxResubmissionAction(container: container) case .finished, .failed, .stopped, .idle: return nil } @@ -667,6 +669,8 @@ extension CompactBlockProcessor { break case .stopped: break + case .txResubmission: + break } } diff --git a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift index 92868c33..fd6c0e8d 100644 --- a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift +++ b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift @@ -108,6 +108,19 @@ class TransactionSQLDAO: TransactionRepository { return try await execute(query) { try ZcashTransaction.Overview(row: $0) } } + func findForResubmission(upTo: BlockHeight) async throws -> [ZcashTransaction.Overview] { + let query = transactionsView + .order((ZcashTransaction.Overview.Column.minedHeight ?? BlockHeight.max).desc) + .filter( + ZcashTransaction.Overview.Column.minedHeight == nil && + ZcashTransaction.Overview.Column.expiryHeight > upTo + ) + .filterQueryFor(kind: .sent) + .limit(Int.max) + + return try await execute(query) { try ZcashTransaction.Overview(row: $0) } + } + func findReceived(offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] { let query = transactionsView .filterQueryFor(kind: .received) diff --git a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift index 5ec4ece2..20d9c83f 100644 --- a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift +++ b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift @@ -19,6 +19,7 @@ protocol TransactionRepository { func findPendingTransactions(latestHeight: BlockHeight, offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] func findReceived(offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] func findSent(offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] + func findForResubmission(upTo: BlockHeight) async throws -> [ZcashTransaction.Overview] // sourcery: mockedName="findMemosForRawID" func findMemos(for rawID: Data) async throws -> [Memo] // sourcery: mockedName="findMemosForZcashTransaction" diff --git a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift index dd32a14f..91bd561c 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift @@ -96,6 +96,25 @@ enum Dependencies { container.register(type: ZcashFileManager.self, isSingleton: true) { _ in FileManager.default } + + container.register(type: TransactionEncoder.self, isSingleton: true) { di in + let service = di.resolve(LightWalletService.self) + let logger = di.resolve(Logger.self) + let transactionRepository = di.resolve(TransactionRepository.self) + let rustBackend = di.resolve(ZcashRustBackendWelding.self) + + return WalletTransactionEncoder( + rustBackend: rustBackend, + dataDb: urls.dataDbURL, + fsBlockDbRoot: urls.fsBlockDbRoot, + service: service, + repository: transactionRepository, + outputParams: urls.outputParamsURL, + spendParams: urls.spendParamsURL, + networkType: networkType, + logger: logger + ) + } } static func setupCompactBlockProcessor( diff --git a/Tests/OfflineTests/CompactBlockProcessorActions/EnhanceActionTests.swift b/Tests/OfflineTests/CompactBlockProcessorActions/EnhanceActionTests.swift index 45e909e3..20d328fe 100644 --- a/Tests/OfflineTests/CompactBlockProcessorActions/EnhanceActionTests.swift +++ b/Tests/OfflineTests/CompactBlockProcessorActions/EnhanceActionTests.swift @@ -46,7 +46,7 @@ final class EnhanceActionTests: ZcashTestCase { XCTAssertTrue(acResult == .true, "Check of state failed with '\(acResult)'") } - func testEnhanceAction_decideWhatToDoNext_UpdateChainTipExpected() async throws { + func testEnhanceAction_decideWhatToDoNext_txResubmissionExpected() async throws { let enhanceAction = setupAction() underlyingDownloadRange = CompactBlockRange(uncheckedBounds: (1000, 2000)) underlyingScanRange = CompactBlockRange(uncheckedBounds: (1000, 2000)) @@ -55,7 +55,7 @@ final class EnhanceActionTests: ZcashTestCase { let nextContext = await enhanceAction.decideWhatToDoNext(context: syncContext, lastScannedHeight: 1500) - let acResult = nextContext.checkStateIs(.updateChainTip) + let acResult = nextContext.checkStateIs(.txResubmission) XCTAssertTrue(acResult == .true, "Check of state failed with '\(acResult)'") } diff --git a/Tests/OfflineTests/CompactBlockProcessorActions/ProcessSuggestedScanRangesActionTests.swift b/Tests/OfflineTests/CompactBlockProcessorActions/ProcessSuggestedScanRangesActionTests.swift index 786ee657..fe7b627c 100644 --- a/Tests/OfflineTests/CompactBlockProcessorActions/ProcessSuggestedScanRangesActionTests.swift +++ b/Tests/OfflineTests/CompactBlockProcessorActions/ProcessSuggestedScanRangesActionTests.swift @@ -40,7 +40,7 @@ final class ProcessSuggestedScanRangesActionTests: ZcashTestCase { let nextContext = try await processSuggestedScanRangesActionAction.run(with: context) { _ in } - let acResult = nextContext.checkStateIs(.finished) + let acResult = nextContext.checkStateIs(.txResubmission) XCTAssertTrue(acResult == .true, "Check of state failed with '\(acResult)'") } catch { XCTFail("testProcessSuggestedScanRangesAction_EmptyScanRanges is not expected to fail. \(error)") diff --git a/Tests/TestUtils/MockTransactionRepository.swift b/Tests/TestUtils/MockTransactionRepository.swift index 3289475d..268808d9 100644 --- a/Tests/TestUtils/MockTransactionRepository.swift +++ b/Tests/TestUtils/MockTransactionRepository.swift @@ -73,6 +73,10 @@ extension MockTransactionRepository.Kind: Equatable {} // MARK: - TransactionRepository extension MockTransactionRepository: TransactionRepository { + func findForResubmission(upTo: ZcashLightClientKit.BlockHeight) async throws -> [ZcashLightClientKit.ZcashTransaction.Overview] { + [] + } + func getTransactionOutputs(for rawID: Data) async throws -> [ZcashLightClientKit.ZcashTransaction.Output] { [] } diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index 06383650..7d2798e3 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -2116,6 +2116,30 @@ class TransactionRepositoryMock: TransactionRepository { } } + // MARK: - findForResubmission + + var findForResubmissionUpToThrowableError: Error? + var findForResubmissionUpToCallsCount = 0 + var findForResubmissionUpToCalled: Bool { + return findForResubmissionUpToCallsCount > 0 + } + var findForResubmissionUpToReceivedUpTo: BlockHeight? + var findForResubmissionUpToReturnValue: [ZcashTransaction.Overview]! + var findForResubmissionUpToClosure: ((BlockHeight) async throws -> [ZcashTransaction.Overview])? + + func findForResubmission(upTo: BlockHeight) async throws -> [ZcashTransaction.Overview] { + if let error = findForResubmissionUpToThrowableError { + throw error + } + findForResubmissionUpToCallsCount += 1 + findForResubmissionUpToReceivedUpTo = upTo + if let closure = findForResubmissionUpToClosure { + return try await closure(upTo) + } else { + return findForResubmissionUpToReturnValue + } + } + // MARK: - findMemos var findMemosForRawIDThrowableError: Error?