From 0b1e1bbcdc766e89ea9ec88ae56c3d199b238a92 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 12 May 2026 15:55:13 -0600 Subject: [PATCH 01/16] fix: retry broadcaster transactions with submit plans --- CHANGELOG.md | 3 + .../Block/Actions/TxResubmissionAction.swift | 26 +- Sources/ZcashLightClientKit/Broadcaster.swift | 8 + .../DAO/TransactionDao.swift | 10 +- .../Repository/TransactionRepository.swift | 4 + .../Synchronizer/Dependencies.swift | 22 ++ .../Synchronizer/SDKBroadcaster.swift | 110 +++++-- .../Synchronizer/SDKSynchronizer.swift | 22 +- .../Transaction/PendingSubmitPlanStore.swift | 284 ++++++++++++++++++ .../Transaction/SubmitPlanExecutor.swift | 114 +++++++ Tests/OfflineTests/BroadcasterTests.swift | 31 +- .../PendingSubmitPlanStoreTests.swift | 282 +++++++++++++++++ 12 files changed, 888 insertions(+), 28 deletions(-) create mode 100644 Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift create mode 100644 Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift create mode 100644 Tests/OfflineTests/PendingSubmitPlanStoreTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f28372c9..2d9c8d687 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Changed +- `Broadcaster` transactions now remain eligible for SDK automatic retry after submission. The SDK records the submitted endpoints privately and retries with those endpoints instead of the synchronizer's default endpoint. + # 2.5.0 - 2026-05-11 ## Added diff --git a/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift index 81104a20a..d9dfcb59a 100644 --- a/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift +++ b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift @@ -15,11 +15,15 @@ final class TxResubmissionAction { var latestResolvedTime: TimeInterval = 0 let transactionRepository: TransactionRepository var transactionEncoder: TransactionEncoder + let pendingSubmitPlanStore: PendingSubmitPlanStore + let submitPlanExecutor: SubmitPlanExecutor let logger: Logger init(container: DIContainer) { transactionRepository = container.resolve(TransactionRepository.self) transactionEncoder = container.resolve(TransactionEncoder.self) + pendingSubmitPlanStore = container.resolve(PendingSubmitPlanStore.self) + submitPlanExecutor = container.resolve(SubmitPlanExecutor.self) logger = container.resolve(Logger.self) } } @@ -34,6 +38,7 @@ extension TxResubmissionAction: Action { do { logger.info("TxResubmissionAction check started at \(latestBlockHeight) height.") let transactions = try await transactionRepository.findForResubmission(upTo: latestBlockHeight) + await pendingSubmitPlanStore.retainPlans(for: transactions.map(\.rawID)) // no candidates, update the time and continue with the next action if transactions.isEmpty { @@ -47,10 +52,29 @@ extension TxResubmissionAction: Action { // resubmission do { for transaction in transactions { + let submitPlan = await pendingSubmitPlanStore.getSubmitPlan(for: transaction.rawID) + if case .awaitingPlan = submitPlan { + logger.info( + "TxResubmissionAction skipping transaction \(transaction.rawID.toHexStringTxId()) " + + "until a submit plan is registered." + ) + continue + } + logger.info("TxResubmissionAction trying to resubmit transaction \(transaction.rawID.toHexStringTxId()).") let encodedTransaction = try transaction.encodedTransaction() - try await transactionEncoder.submit(transaction: encodedTransaction) + switch submitPlan { + case .none: + try await transactionEncoder.submit(transaction: encodedTransaction) + case .ready(let plan): + try await submitPlanExecutor.submit( + transaction: encodedTransaction, + submitPlan: plan + ) + case .awaitingPlan: + break + } } } catch { logger.error("TxResubmissionAction failed to resubmit candidates.") diff --git a/Sources/ZcashLightClientKit/Broadcaster.swift b/Sources/ZcashLightClientKit/Broadcaster.swift index 72fc4fe14..488d2f62d 100644 --- a/Sources/ZcashLightClientKit/Broadcaster.swift +++ b/Sources/ZcashLightClientKit/Broadcaster.swift @@ -16,6 +16,11 @@ import Foundation /// strategies such as submitting to multiple lightwalletd servers /// in parallel. /// +/// Transactions created through this API wait for the caller to submit them. +/// Once submitted, automatic retry uses the submitted endpoints instead of the +/// synchronizer's default endpoint. Retry tries the recorded endpoints in order +/// until one submit succeeds. +/// /// Typical usage: /// ```swift /// // 1. Create the transaction(s) @@ -69,6 +74,9 @@ public protocol Broadcaster: AnyObject { /// transaction, and tears down the connection. Respects the current /// Tor configuration. /// + /// If the transaction was created through this `Broadcaster`, the endpoint + /// is remembered for automatic retry. + /// /// - Parameter rawTransaction: the raw serialized transaction bytes. /// - Parameter endpoint: the `LightWalletEndpoint` to submit to. func submit( diff --git a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift index b648f9595..2d428b494 100644 --- a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift +++ b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift @@ -33,7 +33,7 @@ extension Connection { } } -class TransactionSQLDAO: TransactionRepository { +class TransactionSQLDAO: TransactionRepository, RawTransactionLookup { enum NotesTableStructure { static let transactionID = SQLite.Expression("tx") static let memo = SQLite.Expression("memo") @@ -146,6 +146,14 @@ class TransactionSQLDAO: TransactionRepository { return try await execute(query) { try ZcashTransaction.Overview(row: $0) } } + func find(rawTransaction: Data) async throws -> ZcashTransaction.Overview { + let query = transactionsView + .filter(ZcashTransaction.Overview.Column.raw == Blob(bytes: rawTransaction.bytes)) + .limit(1) + + return try await execute(query) { try ZcashTransaction.Overview(row: $0) } + } + func find(offset: Int, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] { let query = transactionsView .order((ZcashTransaction.Overview.Column.minedHeight ?? BlockHeight.max).desc) diff --git a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift index a79160c9f..d9ed265e1 100644 --- a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift +++ b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift @@ -29,3 +29,7 @@ protocol TransactionRepository { func getTransactionOutputs(for rawID: Data) async throws -> [ZcashTransaction.Output] func debugDatabase(sql: String) -> String } + +protocol RawTransactionLookup { + func find(rawTransaction: Data) async throws -> ZcashTransaction.Overview +} diff --git a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift index 3e9dcaebb..68f20a58b 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift @@ -92,6 +92,17 @@ enum Dependencies { TorClient(torDir: urls.torDirURL) } + container.register(type: PendingSubmitPlanStore.self, isSingleton: true) { di in + let logger = di.resolve(Logger.self) + return PendingSubmitPlanStore( + persistence: KeychainSubmitPlanPersistence( + alias: alias, + networkType: networkType + ), + logger: logger + ) + } + container.register(type: LightWalletService.self, isSingleton: true) { di in let torClient = di.resolve(TorClient.self) @@ -148,6 +159,17 @@ enum Dependencies { container.register(type: ZcashFileManager.self, isSingleton: true) { _ in FileManager.default } + + container.register(type: TransactionSubmitter.self, isSingleton: true) { di in + EndpointTransactionSubmitter( + torClient: di.resolve(TorClient.self), + sdkFlags: di.resolve(SDKFlags.self) + ) + } + + container.register(type: SubmitPlanExecutor.self, isSingleton: true) { di in + SubmitPlanExecutor(transactionSubmitter: di.resolve(TransactionSubmitter.self)) + } container.register(type: TransactionEncoder.self, isSingleton: true) { di in let service = di.resolve(LightWalletService.self) diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift index bb1ce9a3a..d8c551dfd 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift @@ -11,24 +11,30 @@ import Foundation class SDKBroadcaster: Broadcaster { private let transactionEncoder: TransactionEncoder private let initializer: Initializer - private let sdkFlags: SDKFlags private let logger: Logger private let eventSubject: PassthroughSubject private let statusCheck: () throws -> Void + private let pendingSubmitPlanStore: PendingSubmitPlanStore + private let transactionSubmitter: TransactionSubmitter + private let rawTransactionLookup: RawTransactionLookup? init( transactionEncoder: TransactionEncoder, initializer: Initializer, - sdkFlags: SDKFlags, logger: Logger, eventSubject: PassthroughSubject, + pendingSubmitPlanStore: PendingSubmitPlanStore, + transactionSubmitter: TransactionSubmitter, + rawTransactionLookup: RawTransactionLookup?, statusCheck: @escaping () throws -> Void ) { self.transactionEncoder = transactionEncoder self.initializer = initializer - self.sdkFlags = sdkFlags self.logger = logger self.eventSubject = eventSubject + self.pendingSubmitPlanStore = pendingSubmitPlanStore + self.transactionSubmitter = transactionSubmitter + self.rawTransactionLookup = rawTransactionLookup self.statusCheck = statusCheck } @@ -51,13 +57,24 @@ class SDKBroadcaster: Broadcaster { spendingKey: spendingKey ) - if !transactions.isEmpty { - eventSubject.send(.foundTransactions(transactions, nil)) - } + await pendingSubmitPlanStore.markAwaitingSubmitPlan(transactions) + sendFoundTransactionsEvent(transactions) return transactions } + func createProposedTransactionsForLegacySubmit( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] { + let transactions = try await createProposedTransactionsWithoutRegisteringSubmitPlan( + proposal: proposal, + spendingKey: spendingKey + ) + sendFoundTransactionsEvent(transactions) + return transactions + } + func createTransactionFromPCZT( pcztWithProofs: Pczt, pcztWithSigs: Pczt @@ -78,11 +95,21 @@ class SDKBroadcaster: Broadcaster { ) let transactions = try await transactionEncoder.fetchTransactionsForTxIds([txId]) + await pendingSubmitPlanStore.markAwaitingSubmitPlan(transactions) + sendFoundTransactionsEvent(transactions) - if !transactions.isEmpty { - eventSubject.send(.foundTransactions(transactions, nil)) - } + return transactions + } + func createTransactionFromPCZTForLegacySubmit( + pcztWithProofs: Pczt, + pcztWithSigs: Pczt + ) async throws -> [ZcashTransaction.Overview] { + let transactions = try await createTransactionFromPCZTWithoutRegisteringSubmitPlan( + pcztWithProofs: pcztWithProofs, + pcztWithSigs: pcztWithSigs + ) + sendFoundTransactionsEvent(transactions) return transactions } @@ -90,18 +117,63 @@ class SDKBroadcaster: Broadcaster { _ rawTransaction: Data, to endpoint: LightWalletEndpoint ) async throws { - let torClient = initializer.container.resolve(TorClient.self) - let service = LightWalletGRPCServiceOverTor(endpoint: endpoint, tor: torClient) - defer { Task { await service.closeConnections() } } + if let transaction = try? await rawTransactionLookup?.find(rawTransaction: rawTransaction) { + await pendingSubmitPlanStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) + try await transactionSubmitter.submit( + transaction: EncodedTransaction(transactionId: transaction.rawID, raw: rawTransaction), + to: endpoint + ) + } else { + await pendingSubmitPlanStore.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) + try await transactionSubmitter.submit(rawTransaction: rawTransaction, to: endpoint) + } + } - let mode: ServiceMode = await sdkFlags.torEnabled ? .uniqueTor : .direct - let response = try await service.submit(spendTransaction: rawTransaction, mode: mode) + private func createProposedTransactionsWithoutRegisteringSubmitPlan( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] { + try statusCheck() - guard response.errorCode >= 0 else { - throw TransactionEncoderError.submitError( - code: Int(response.errorCode), - message: response.errorMessage - ) + try await SaplingParameterDownloader.downloadParamsIfnotPresent( + spendURL: initializer.spendParamsURL, + spendSourceURL: initializer.saplingParamsSourceURL.spendParamFileURL, + outputURL: initializer.outputParamsURL, + outputSourceURL: initializer.saplingParamsSourceURL.outputParamFileURL, + logger: logger + ) + + return try await transactionEncoder.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + } + + private func createTransactionFromPCZTWithoutRegisteringSubmitPlan( + pcztWithProofs: Pczt, + pcztWithSigs: Pczt + ) async throws -> [ZcashTransaction.Overview] { + try statusCheck() + + try await SaplingParameterDownloader.downloadParamsIfnotPresent( + spendURL: initializer.spendParamsURL, + spendSourceURL: initializer.saplingParamsSourceURL.spendParamFileURL, + outputURL: initializer.outputParamsURL, + outputSourceURL: initializer.saplingParamsSourceURL.outputParamFileURL, + logger: logger + ) + + let txId = try await initializer.rustBackend.extractAndStoreTxFromPCZT( + pcztWithProofs: pcztWithProofs, + pcztWithSigs: pcztWithSigs + ) + + return try await transactionEncoder.fetchTransactionsForTxIds([txId]) + } + + private func sendFoundTransactionsEvent(_ transactions: [ZcashTransaction.Overview]) { + if !transactions.isEmpty { + eventSubject.send(.foundTransactions(transactions, nil)) } } } diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index d0f092dc0..c9e7bce20 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -49,13 +49,14 @@ public class SDKSynchronizer: Synchronizer { public let network: ZcashNetwork private var transactionEncoder: TransactionEncoder private let transactionRepository: TransactionRepository + private let pendingSubmitPlanStore: PendingSubmitPlanStore private let syncSessionIDGenerator: SyncSessionIDGenerator private let syncSession: SyncSession private let syncSessionTicker: SessionTicker var latestBlocksDataProvider: LatestBlocksDataProvider - private var broadcasterStorage: Broadcaster? + private var broadcasterStorage: SDKBroadcaster? public var broadcaster: Broadcaster { guard let broadcaster = broadcasterStorage else { preconditionFailure("Broadcaster accessed before initialization") @@ -101,13 +102,16 @@ public class SDKSynchronizer: Synchronizer { self.syncSessionTicker = syncSessionTicker self.latestBlocksDataProvider = initializer.container.resolve(LatestBlocksDataProvider.self) self.sdkFlags = initializer.container.resolve(SDKFlags.self) + self.pendingSubmitPlanStore = initializer.container.resolve(PendingSubmitPlanStore.self) self.broadcasterStorage = SDKBroadcaster( transactionEncoder: transactionEncoder, initializer: initializer, - sdkFlags: sdkFlags, logger: logger, eventSubject: eventSubject, + pendingSubmitPlanStore: pendingSubmitPlanStore, + transactionSubmitter: initializer.container.resolve(TransactionSubmitter.self), + rawTransactionLookup: transactionRepository as? RawTransactionLookup, statusCheck: { [weak self] in guard let self else { throw ZcashError.synchronizerNotPrepared @@ -460,7 +464,7 @@ public class SDKSynchronizer: Synchronizer { proposal: Proposal, spendingKey: UnifiedSpendingKey ) async throws -> AsyncThrowingStream { - let transactions = try await broadcaster.createProposedTransactions( + let transactions = try await sdkBroadcaster().createProposedTransactionsForLegacySubmit( proposal: proposal, spendingKey: spendingKey ) @@ -532,7 +536,7 @@ public class SDKSynchronizer: Synchronizer { } public func createTransactionFromPCZT(pcztWithProofs: Pczt, pcztWithSigs: Pczt) async throws -> AsyncThrowingStream { - let transactions = try await broadcaster.createTransactionFromPCZT( + let transactions = try await sdkBroadcaster().createTransactionFromPCZTForLegacySubmit( pcztWithProofs: pcztWithProofs, pcztWithSigs: pcztWithSigs ) @@ -760,6 +764,9 @@ public class SDKSynchronizer: Synchronizer { self?.transactionRepository.closeDBConnection() }, completion: { [weak self] possibleError in + if possibleError == nil { + await self?.pendingSubmitPlanStore.clear() + } await self?.updateStatus(.unprepared) if let error = possibleError { subject.send(completion: .failure(error)) @@ -783,6 +790,13 @@ public class SDKSynchronizer: Synchronizer { try await initializer.rustBackend.isSeedRelevantToAnyDerivedAccount(seed: seed) } + private func sdkBroadcaster() -> SDKBroadcaster { + guard let broadcaster = broadcasterStorage else { + preconditionFailure("Broadcaster accessed before initialization") + } + return broadcaster + } + /// Takes the list of endpoints and runs it through a series of checks to evaluate its performance. /// - Parameters: /// - endpoints: Array of endpoints to evaluate. diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift new file mode 100644 index 000000000..a8800bd82 --- /dev/null +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -0,0 +1,284 @@ +// +// PendingSubmitPlanStore.swift +// ZcashLightClientKit +// +// Created by Adam Tucker on 2026-05-12. +// + +import Foundation +import Security + +protocol PendingSubmitPlanPersistence { + func load() throws -> Data? + func save(_ data: Data) throws + func clear() throws +} + +struct TransactionSubmitPlan { + let endpoints: [LightWalletEndpoint] + + init(endpoints: [LightWalletEndpoint]) { + precondition(!endpoints.isEmpty, "Transaction submit plan must include at least one endpoint.") + self.endpoints = endpoints + } +} + +actor PendingSubmitPlanStore { + enum StoredSubmitPlan { + case awaitingPlan + case ready(TransactionSubmitPlan) + } + + private let persistence: PendingSubmitPlanPersistence? + private let logger: Logger + + private var plansByTransactionId: [String: [StoredEndpoint]] = [:] + private var transactionIdsByRawTransaction: [String: String] = [:] + private var loadedFromPersistence = false + + init( + persistence: PendingSubmitPlanPersistence? = nil, + logger: Logger + ) { + self.persistence = persistence + self.logger = logger + } + + func markAwaitingSubmitPlan(_ transactions: [ZcashTransaction.Overview]) { + loadFromPersistenceIfNeeded() + + var changed = false + for transaction in transactions { + let transactionId = transaction.rawID.stablePlanKey + if let raw = transaction.raw { + transactionIdsByRawTransaction[raw.stablePlanKey] = transactionId + } + if plansByTransactionId[transactionId] == nil { + plansByTransactionId[transactionId] = [] + changed = true + } + } + + if changed { + saveToPersistence() + } + } + + func addSubmitEndpoint( + rawTransaction: Data, + endpoint: LightWalletEndpoint + ) { + loadFromPersistenceIfNeeded() + + guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { + return + } + + addSubmitEndpoint(transactionId: transactionId, endpoint: endpoint) + } + + func addSubmitEndpoint( + transaction: ZcashTransaction.Overview, + endpoint: LightWalletEndpoint + ) { + loadFromPersistenceIfNeeded() + if let raw = transaction.raw { + transactionIdsByRawTransaction[raw.stablePlanKey] = transaction.rawID.stablePlanKey + } + addSubmitEndpoint(transactionId: transaction.rawID.stablePlanKey, endpoint: endpoint) + } + + func getSubmitPlan(for transactionId: Data) -> StoredSubmitPlan? { + loadFromPersistenceIfNeeded() + + switch plansByTransactionId[transactionId.stablePlanKey] { + case nil: + return nil + case let endpoints? where endpoints.isEmpty: + return .awaitingPlan + case let endpoints?: + return .ready(TransactionSubmitPlan(endpoints: endpoints.map(\.endpoint))) + } + } + + func retainPlans(for transactionIds: [Data]) { + loadFromPersistenceIfNeeded() + + let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) + let previousCount = plansByTransactionId.count + plansByTransactionId = plansByTransactionId.filter { retainedTransactionIds.contains($0.key) } + + if plansByTransactionId.count != previousCount { + saveToPersistence() + } + } + + func clear() { + plansByTransactionId.removeAll() + transactionIdsByRawTransaction.removeAll() + do { + try persistence?.clear() + } catch { + logger.warn("Failed to clear pending submit plans: \(error)") + } + } + + private func addSubmitEndpoint( + transactionId: String, + endpoint: LightWalletEndpoint + ) { + let storedEndpoint = StoredEndpoint(endpoint: endpoint) + var endpoints = plansByTransactionId[transactionId] ?? [] + guard !endpoints.contains(storedEndpoint) else { return } + + endpoints.append(storedEndpoint) + plansByTransactionId[transactionId] = endpoints + saveToPersistence() + } + + private func loadFromPersistenceIfNeeded() { + guard !loadedFromPersistence else { return } + defer { loadedFromPersistence = true } + + do { + guard + let data = try persistence?.load(), + !data.isEmpty + else { + return + } + + let storedPlans = try JSONDecoder().decode(StoredPlans.self, from: data) + plansByTransactionId = storedPlans.plansByTransactionId + } catch { + logger.warn("Failed to load pending submit plans: \(error)") + } + } + + private func saveToPersistence() { + do { + let storedPlans = StoredPlans(plansByTransactionId: plansByTransactionId) + let data = try JSONEncoder().encode(storedPlans) + try persistence?.save(data) + } catch { + logger.warn("Failed to store pending submit plans: \(error)") + } + } +} + +private struct StoredPlans: Codable { + let version: Int + let plansByTransactionId: [String: [StoredEndpoint]] + + init( + version: Int = 1, + plansByTransactionId: [String: [StoredEndpoint]] + ) { + self.version = version + self.plansByTransactionId = plansByTransactionId + } +} + +private struct StoredEndpoint: Codable, Equatable { + let host: String + let port: Int + let secure: Bool + let singleCallTimeoutInMillis: Int64 + let streamingCallTimeoutInMillis: Int64 + + init(endpoint: LightWalletEndpoint) { + host = endpoint.host + port = endpoint.port + secure = endpoint.secure + singleCallTimeoutInMillis = endpoint.singleCallTimeoutInMillis + streamingCallTimeoutInMillis = endpoint.streamingCallTimeoutInMillis + } + + var endpoint: LightWalletEndpoint { + LightWalletEndpoint( + address: host, + port: port, + secure: secure, + singleCallTimeoutInMillis: singleCallTimeoutInMillis, + streamingCallTimeoutInMillis: streamingCallTimeoutInMillis + ) + } +} + +private extension Data { + var stablePlanKey: String { hexEncodedString() } +} + +struct KeychainSubmitPlanPersistence: PendingSubmitPlanPersistence { + private enum Constants { + static let service = "cash.z.ecc.ZcashLightClientKit.pending-submit-plans" + } + + private let account: String + + init( + alias: ZcashSynchronizerAlias, + networkType: NetworkType + ) { + self.account = "\(networkType.networkId)_\(alias.description)" + } + + func load() throws -> Data? { + var query = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + + switch status { + case errSecSuccess: + return item as? Data + case errSecItemNotFound: + return nil + default: + throw KeychainSubmitPlanPersistenceError.unhandledStatus(status) + } + } + + func save(_ data: Data) throws { + let attributes: [String: Any] = [ + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly + ] + + let updateStatus = SecItemUpdate(baseQuery as CFDictionary, attributes as CFDictionary) + switch updateStatus { + case errSecSuccess: + return + case errSecItemNotFound: + var addQuery = baseQuery + attributes.forEach { addQuery[$0.key] = $0.value } + let addStatus = SecItemAdd(addQuery as CFDictionary, nil) + guard addStatus == errSecSuccess else { + throw KeychainSubmitPlanPersistenceError.unhandledStatus(addStatus) + } + default: + throw KeychainSubmitPlanPersistenceError.unhandledStatus(updateStatus) + } + } + + func clear() throws { + let status = SecItemDelete(baseQuery as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw KeychainSubmitPlanPersistenceError.unhandledStatus(status) + } + } + + private var baseQuery: [String: Any] { + [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Constants.service, + kSecAttrAccount as String: account + ] + } +} + +private enum KeychainSubmitPlanPersistenceError: Error { + case unhandledStatus(OSStatus) +} diff --git a/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift b/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift new file mode 100644 index 000000000..627c8a2ec --- /dev/null +++ b/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift @@ -0,0 +1,114 @@ +// +// SubmitPlanExecutor.swift +// ZcashLightClientKit +// +// Created by Adam Tucker on 2026-05-12. +// + +import Foundation + +protocol TransactionSubmitter { + func submit( + rawTransaction: Data, + to endpoint: LightWalletEndpoint + ) async throws + + func submit( + transaction: EncodedTransaction, + to endpoint: LightWalletEndpoint + ) async throws +} + +final class EndpointTransactionSubmitter: TransactionSubmitter { + private let torClient: TorClient + private let sdkFlags: SDKFlags + + init( + torClient: TorClient, + sdkFlags: SDKFlags + ) { + self.torClient = torClient + self.sdkFlags = sdkFlags + } + + func submit( + rawTransaction: Data, + to endpoint: LightWalletEndpoint + ) async throws { + try await submit( + rawTransaction: rawTransaction, + to: endpoint, + mode: await sdkFlags.torEnabled ? .uniqueTor : .direct + ) + } + + func submit( + transaction: EncodedTransaction, + to endpoint: LightWalletEndpoint + ) async throws { + let mode: ServiceMode + if await sdkFlags.torEnabled { + mode = ServiceMode.txIdGroup(prefix: "submit", txId: transaction.transactionId) + } else { + mode = .direct + } + + try await submit( + rawTransaction: transaction.raw, + to: endpoint, + mode: mode + ) + } + + private func submit( + rawTransaction: Data, + to endpoint: LightWalletEndpoint, + mode: ServiceMode + ) async throws { + let service = LightWalletGRPCServiceOverTor(endpoint: endpoint, tor: torClient) + let response: any LightWalletServiceResponse + do { + response = try await service.submit(spendTransaction: rawTransaction, mode: mode) + } catch { + await service.closeConnections() + throw error + } + + await service.closeConnections() + + guard response.errorCode >= 0 else { + throw TransactionEncoderError.submitError( + code: Int(response.errorCode), + message: response.errorMessage + ) + } + } +} + +final class SubmitPlanExecutor { + private let transactionSubmitter: TransactionSubmitter + + init(transactionSubmitter: TransactionSubmitter) { + self.transactionSubmitter = transactionSubmitter + } + + func submit( + transaction: EncodedTransaction, + submitPlan: TransactionSubmitPlan + ) async throws { + var lastError: Error? + + for endpoint in submitPlan.endpoints { + do { + try await transactionSubmitter.submit(transaction: transaction, to: endpoint) + return + } catch { + lastError = error + } + } + + if let lastError { + throw lastError + } + } +} diff --git a/Tests/OfflineTests/BroadcasterTests.swift b/Tests/OfflineTests/BroadcasterTests.swift index 4b5b480fd..db347d56f 100644 --- a/Tests/OfflineTests/BroadcasterTests.swift +++ b/Tests/OfflineTests/BroadcasterTests.swift @@ -30,7 +30,11 @@ final class BroadcasterTests: ZcashTestCase { let rawTransaction = Data([0x01, 0x02, 0x03, 0x04]) let createdTransactions = [makeTransaction(raw: rawTransaction, rawID: Data(repeating: 0xAB, count: 32))] let transactionEncoder = StubTransactionEncoder(createdTransactions: createdTransactions) - let synchronizer = try makeSynchronizer(transactionEncoder: transactionEncoder) + let pendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + pendingSubmitPlanStore: pendingSubmitPlanStore + ) let foundTransactionsExpectation = XCTestExpectation(description: "found transactions event") @@ -57,6 +61,12 @@ final class BroadcasterTests: ZcashTestCase { XCTAssertEqual(transactionEncoder.receivedCreateArguments?.spendingKey, spendingKey) XCTAssertEqual(transactions.map(\.rawID), createdTransactions.map(\.rawID)) XCTAssertEqual(try transactions.map { try XCTUnwrap($0.raw) }, [rawTransaction]) + switch await pendingSubmitPlanStore.getSubmitPlan(for: createdTransactions[0].rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected created broadcaster transaction to wait for a submit plan.") + } await fulfillment(of: [foundTransactionsExpectation], timeout: 1.0) } @@ -86,9 +96,13 @@ final class BroadcasterTests: ZcashTestCase { let rawTransaction = Data([0x01, 0x02, 0x03, 0x04]) let createdTransactions = [makeTransaction(raw: rawTransaction, rawID: Data(repeating: 0xAB, count: 32))] let transactionEncoder = StubTransactionEncoder(createdTransactions: createdTransactions) + let pendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) let service = try RecordingCompactTxStreamerService(sendResponse: makeSendResponse(errorCode: 0, errorMessage: "")) defer { try? service.stop() } - let synchronizer = try makeSynchronizer(transactionEncoder: transactionEncoder) + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + pendingSubmitPlanStore: pendingSubmitPlanStore + ) await synchronizer.updateStatus(.stopped) @@ -108,6 +122,15 @@ final class BroadcasterTests: ZcashTestCase { try await synchronizer.broadcaster.submit(raw, to: service.endpoint) XCTAssertEqual(service.recordedTransactions(), [rawTransaction]) + switch await pendingSubmitPlanStore.getSubmitPlan(for: createdTransactions[0].rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + XCTAssertEqual(plan.endpoints[0].host, service.endpoint.host) + XCTAssertEqual(plan.endpoints[0].port, service.endpoint.port) + XCTAssertEqual(plan.endpoints[0].secure, service.endpoint.secure) + default: + XCTFail("Expected submit endpoint to be registered for retry.") + } } func testBroadcasterThrowsWhenNotPrepared() async throws { @@ -264,7 +287,8 @@ final class BroadcasterTests: ZcashTestCase { private func makeSynchronizer( transactionEncoder: TransactionEncoder, - rustBackend: ZcashRustBackendWelding? = nil + rustBackend: ZcashRustBackendWelding? = nil, + pendingSubmitPlanStore: PendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) ) throws -> SDKSynchronizer { let serviceMock = LightWalletServiceMock() let transactionRepository = TransactionRepositoryMock() @@ -274,6 +298,7 @@ final class BroadcasterTests: ZcashTestCase { } mockContainer.mock(type: LightWalletService.self, isSingleton: true) { _ in serviceMock } mockContainer.mock(type: TransactionRepository.self, isSingleton: true) { _ in transactionRepository } + mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in pendingSubmitPlanStore } let initializer = Initializer( container: mockContainer, diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift new file mode 100644 index 000000000..9a5ea3f14 --- /dev/null +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -0,0 +1,282 @@ +// +// PendingSubmitPlanStoreTests.swift +// ZcashLightClientKitTests +// + +import XCTest +@testable import TestUtils +@testable import ZcashLightClientKit + +final class PendingSubmitPlanStoreTests: ZcashTestCase { + func testCreatedTransactionsWaitForSubmitPlan() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + + await store.markAwaitingSubmitPlan([transaction]) + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected transaction to wait for a submit plan.") + } + } + + func testPersistsSubmitPlans() async throws { + let persistence = InMemorySubmitPlanPersistence() + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let endpoint = LightWalletEndpointBuilder.default + let firstStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + + await firstStore.markAwaitingSubmitPlan([transaction]) + await firstStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) + + let secondStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + switch await secondStore.getSubmitPlan(for: transaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: endpoint) + default: + XCTFail("Expected persisted submit plan.") + } + } + + func testAddsSubmittedEndpointsToExistingPlan() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let firstEndpoint = LightWalletEndpoint(address: "a.z.cash", port: 443, secure: true) + let secondEndpoint = LightWalletEndpoint(address: "b.z.cash", port: 443, secure: true) + let store = PendingSubmitPlanStore(logger: NullLogger()) + + await store.markAwaitingSubmitPlan([transaction]) + await store.addSubmitEndpoint(transaction: transaction, endpoint: firstEndpoint) + await store.addSubmitEndpoint(transaction: transaction, endpoint: secondEndpoint) + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 2) + assertEndpoint(plan.endpoints[0], equals: firstEndpoint) + assertEndpoint(plan.endpoints[1], equals: secondEndpoint) + default: + XCTFail("Expected submit plan with both endpoints.") + } + } + + func testPrunesPlansThatAreNoLongerResubmissionCandidates() async throws { + let persistence = InMemorySubmitPlanPersistence() + let retainedTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let prunedTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) + let store = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + + await store.markAwaitingSubmitPlan([retainedTransaction, prunedTransaction]) + await store.addSubmitEndpoint(transaction: retainedTransaction, endpoint: LightWalletEndpointBuilder.default) + await store.addSubmitEndpoint(transaction: prunedTransaction, endpoint: LightWalletEndpointBuilder.eccTestnet) + await store.retainPlans(for: [retainedTransaction.rawID]) + + let reloadedStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + let retainedPlan = await reloadedStore.getSubmitPlan(for: retainedTransaction.rawID) + let prunedPlan = await reloadedStore.getSubmitPlan(for: prunedTransaction.rawID) + XCTAssertNotNil(retainedPlan) + XCTAssertNil(prunedPlan) + } + + func testTxResubmissionSkipsTransactionsAwaitingSubmitPlan() async throws { + let awaitingTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let legacyTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) + let transactionRepository = TransactionRepositoryMock() + let transactionEncoder = RecordingTransactionEncoder() + let store = PendingSubmitPlanStore(logger: NullLogger()) + let submitter = RecordingTransactionSubmitter() + + transactionRepository.findForResubmissionUpToReturnValue = [ + awaitingTransaction, + legacyTransaction + ] + + mockContainer.mock(type: TransactionRepository.self, isSingleton: true) { _ in transactionRepository } + mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } + mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } + mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in + SubmitPlanExecutor(transactionSubmitter: submitter) + } + mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } + + await store.markAwaitingSubmitPlan([awaitingTransaction]) + + let action = TxResubmissionAction(container: mockContainer) + action.latestResolvedTime = 0 + _ = try await action.run(with: resubmissionContext()) { _ in } + + XCTAssertEqual(transactionEncoder.submittedTransactions.map(\.transactionId), [legacyTransaction.rawID]) + XCTAssertTrue(submitter.submissions.isEmpty) + } + + func testTxResubmissionUsesRegisteredSubmitPlan() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let endpoint = LightWalletEndpoint(address: "submit.z.cash", port: 443, secure: true) + let transactionRepository = TransactionRepositoryMock() + let transactionEncoder = RecordingTransactionEncoder() + let store = PendingSubmitPlanStore(logger: NullLogger()) + let submitter = RecordingTransactionSubmitter() + + transactionRepository.findForResubmissionUpToReturnValue = [transaction] + + mockContainer.mock(type: TransactionRepository.self, isSingleton: true) { _ in transactionRepository } + mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } + mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } + mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in + SubmitPlanExecutor(transactionSubmitter: submitter) + } + mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } + + await store.markAwaitingSubmitPlan([transaction]) + await store.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) + + let action = TxResubmissionAction(container: mockContainer) + action.latestResolvedTime = 0 + _ = try await action.run(with: resubmissionContext()) { _ in } + + XCTAssertTrue(transactionEncoder.submittedTransactions.isEmpty) + XCTAssertEqual(submitter.submissions.map(\.transaction.transactionId), [transaction.rawID]) + assertEndpoint(try XCTUnwrap(submitter.submissions.first?.endpoint), equals: endpoint) + } + + private func resubmissionContext() -> ActionContextMock { + let context = ActionContextMock.default() + context.underlyingSyncControlData = SyncControlData( + latestBlockHeight: 1000, + latestScannedHeight: nil, + firstUnenhancedHeight: nil + ) + return context + } + + private func makeTransaction(rawID: Data) -> ZcashTransaction.Overview { + ZcashTransaction.Overview( + accountUUID: TestsData.mockedAccountUUID, + blockTime: nil, + expiryHeight: 123_456, + fee: Zatoshi(10_000), + index: 0, + isShielding: false, + hasChange: false, + memoCount: 0, + minedHeight: nil, + raw: Data([0x01, 0x02]), + rawID: rawID, + receivedNoteCount: 0, + sentNoteCount: 1, + value: Zatoshi(-1_000), + isExpiredUmined: false, + totalSpent: nil, + totalReceived: nil + ) + } + + private func assertEndpoint( + _ actual: LightWalletEndpoint, + equals expected: LightWalletEndpoint, + file: StaticString = #file, + line: UInt = #line + ) { + XCTAssertEqual(actual.host, expected.host, file: file, line: line) + XCTAssertEqual(actual.port, expected.port, file: file, line: line) + XCTAssertEqual(actual.secure, expected.secure, file: file, line: line) + XCTAssertEqual( + actual.singleCallTimeoutInMillis, + expected.singleCallTimeoutInMillis, + file: file, + line: line + ) + XCTAssertEqual( + actual.streamingCallTimeoutInMillis, + expected.streamingCallTimeoutInMillis, + file: file, + line: line + ) + } +} + +private final class InMemorySubmitPlanPersistence: PendingSubmitPlanPersistence { + var data: Data? + + func load() throws -> Data? { + data + } + + func save(_ data: Data) throws { + self.data = data + } + + func clear() throws { + data = nil + } +} + +private final class RecordingTransactionEncoder: TransactionEncoder { + private(set) var submittedTransactions: [EncodedTransaction] = [] + + func proposeTransfer( + accountUUID: AccountUUID, + recipient: String, + amount: Zatoshi, + memoBytes: MemoBytes? + ) async throws -> Proposal { + fatalError("Unused in test") + } + + func proposeShielding( + accountUUID: AccountUUID, + shieldingThreshold: Zatoshi, + memoBytes: MemoBytes?, + transparentReceiver: String? + ) async throws -> Proposal? { + fatalError("Unused in test") + } + + func createProposedTransactions( + proposal: Proposal, + spendingKey: UnifiedSpendingKey + ) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func proposeFulfillingPaymentFromURI( + _ uri: String, + accountUUID: AccountUUID + ) async throws -> Proposal { + fatalError("Unused in test") + } + + func submit(transaction: EncodedTransaction) async throws { + submittedTransactions.append(transaction) + } + + func fetchTransactionsForTxIds(_ txIds: [Data]) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func closeDBConnection() { } +} + +private final class RecordingTransactionSubmitter: TransactionSubmitter { + struct Submission { + let transaction: EncodedTransaction + let endpoint: LightWalletEndpoint + } + + private(set) var submissions: [Submission] = [] + + func submit( + rawTransaction: Data, + to endpoint: LightWalletEndpoint + ) async throws { + fatalError("Unused in test") + } + + func submit( + transaction: EncodedTransaction, + to endpoint: LightWalletEndpoint + ) async throws { + submissions.append(Submission(transaction: transaction, endpoint: endpoint)) + } +} From a73ceabac7037d3c0c6d152e3d6aa154a95ee4a4 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 12 May 2026 16:38:23 -0600 Subject: [PATCH 02/16] Cover raw submit plan endpoint registration --- .../Synchronizer/SDKBroadcaster.swift | 1 + .../PendingSubmitPlanStoreTests.swift | 25 +++++++++++++++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift index d8c551dfd..3908b200d 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift @@ -117,6 +117,7 @@ class SDKBroadcaster: Broadcaster { _ rawTransaction: Data, to endpoint: LightWalletEndpoint ) async throws { + // Record before submitting so transient endpoint failures keep the same retry plan. if let transaction = try? await rawTransactionLookup?.find(rawTransaction: rawTransaction) { await pendingSubmitPlanStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) try await transactionSubmitter.submit( diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index 9a5ea3f14..fd233786c 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -61,6 +61,24 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { } } + func testAddsSubmittedEndpointByRawTransaction() async throws { + let rawTransaction = Data([0x01, 0x02]) + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32), raw: rawTransaction) + let endpoint = LightWalletEndpoint(address: "submit.z.cash", port: 443, secure: true) + let store = PendingSubmitPlanStore(logger: NullLogger()) + + await store.markAwaitingSubmitPlan([transaction]) + await store.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: endpoint) + default: + XCTFail("Expected raw transaction submit path to register the endpoint.") + } + } + func testPrunesPlansThatAreNoLongerResubmissionCandidates() async throws { let persistence = InMemorySubmitPlanPersistence() let retainedTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) @@ -150,7 +168,10 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { return context } - private func makeTransaction(rawID: Data) -> ZcashTransaction.Overview { + private func makeTransaction( + rawID: Data, + raw: Data = Data([0x01, 0x02]) + ) -> ZcashTransaction.Overview { ZcashTransaction.Overview( accountUUID: TestsData.mockedAccountUUID, blockTime: nil, @@ -161,7 +182,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { hasChange: false, memoCount: 0, minedHeight: nil, - raw: Data([0x01, 0x02]), + raw: raw, rawID: rawID, receivedNoteCount: 0, sentNoteCount: 1, From ca6d5d20fd314fd29d8b6b2b20846a71b46ebf4d Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 12 May 2026 17:07:25 -0600 Subject: [PATCH 03/16] Prune raw submit plan lookup entries --- .../Transaction/PendingSubmitPlanStore.swift | 5 +++-- Tests/OfflineTests/PendingSubmitPlanStoreTests.swift | 12 ++++++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index a8800bd82..8b24ef8fa 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -105,10 +105,11 @@ actor PendingSubmitPlanStore { loadFromPersistenceIfNeeded() let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) - let previousCount = plansByTransactionId.count + let previousPlanCount = plansByTransactionId.count plansByTransactionId = plansByTransactionId.filter { retainedTransactionIds.contains($0.key) } + transactionIdsByRawTransaction = transactionIdsByRawTransaction.filter { retainedTransactionIds.contains($0.value) } - if plansByTransactionId.count != previousCount { + if plansByTransactionId.count != previousPlanCount { saveToPersistence() } } diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index fd233786c..c2509bbb3 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -81,14 +81,22 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { func testPrunesPlansThatAreNoLongerResubmissionCandidates() async throws { let persistence = InMemorySubmitPlanPersistence() - let retainedTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) - let prunedTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) + let prunedRawTransaction = Data([0x03, 0x04]) + let retainedTransaction = makeTransaction( + rawID: Data(repeating: 0xAB, count: 32), + raw: Data([0x01, 0x02]) + ) + let prunedTransaction = makeTransaction( + rawID: Data(repeating: 0xCD, count: 32), + raw: prunedRawTransaction + ) let store = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) await store.markAwaitingSubmitPlan([retainedTransaction, prunedTransaction]) await store.addSubmitEndpoint(transaction: retainedTransaction, endpoint: LightWalletEndpointBuilder.default) await store.addSubmitEndpoint(transaction: prunedTransaction, endpoint: LightWalletEndpointBuilder.eccTestnet) await store.retainPlans(for: [retainedTransaction.rawID]) + await store.addSubmitEndpoint(rawTransaction: prunedRawTransaction, endpoint: LightWalletEndpointBuilder.eccTestnet) let reloadedStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) let retainedPlan = await reloadedStore.getSubmitPlan(for: retainedTransaction.rawID) From 4b3a786a4373b435f4cff83c8393a01e3790ce71 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 12 May 2026 20:45:22 -0600 Subject: [PATCH 04/16] Cover raw submit lookup path --- Tests/OfflineTests/BroadcasterTests.swift | 117 +++++++++++++++++++++- 1 file changed, 114 insertions(+), 3 deletions(-) diff --git a/Tests/OfflineTests/BroadcasterTests.swift b/Tests/OfflineTests/BroadcasterTests.swift index db347d56f..ae0c8eeeb 100644 --- a/Tests/OfflineTests/BroadcasterTests.swift +++ b/Tests/OfflineTests/BroadcasterTests.swift @@ -133,6 +133,39 @@ final class BroadcasterTests: ZcashTestCase { } } + func testSubmitUsesRawTransactionLookupWhenRawMappingIsNotInMemory() async throws { + let rawID = Data(repeating: 0xAB, count: 32) + let rawTransaction = Data([0x01, 0x02, 0x03, 0x04]) + let pendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) + let storedTransaction = makeTransaction(raw: nil, rawID: rawID) + let lookupTransaction = makeTransaction(raw: rawTransaction, rawID: rawID) + let transactionRepository = LookupTransactionRepository(transaction: lookupTransaction) + let transactionEncoder = StubTransactionEncoder(createdTransactions: []) + let service = try RecordingCompactTxStreamerService(sendResponse: makeSendResponse(errorCode: 0, errorMessage: "")) + defer { try? service.stop() } + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + pendingSubmitPlanStore: pendingSubmitPlanStore, + transactionRepository: transactionRepository + ) + + await pendingSubmitPlanStore.markAwaitingSubmitPlan([storedTransaction]) + + try await synchronizer.broadcaster.submit(rawTransaction, to: service.endpoint) + + XCTAssertEqual(transactionRepository.receivedRawTransactions, [rawTransaction]) + XCTAssertEqual(service.recordedTransactions(), [rawTransaction]) + switch await pendingSubmitPlanStore.getSubmitPlan(for: rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + XCTAssertEqual(plan.endpoints[0].host, service.endpoint.host) + XCTAssertEqual(plan.endpoints[0].port, service.endpoint.port) + XCTAssertEqual(plan.endpoints[0].secure, service.endpoint.secure) + default: + XCTFail("Expected raw lookup path to register the endpoint for retry.") + } + } + func testBroadcasterThrowsWhenNotPrepared() async throws { let transactionEncoder = StubTransactionEncoder(createdTransactions: []) let synchronizer = try makeSynchronizer(transactionEncoder: transactionEncoder) @@ -288,10 +321,10 @@ final class BroadcasterTests: ZcashTestCase { private func makeSynchronizer( transactionEncoder: TransactionEncoder, rustBackend: ZcashRustBackendWelding? = nil, - pendingSubmitPlanStore: PendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) + pendingSubmitPlanStore: PendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()), + transactionRepository: TransactionRepository = TransactionRepositoryMock() ) throws -> SDKSynchronizer { let serviceMock = LightWalletServiceMock() - let transactionRepository = TransactionRepositoryMock() if let rustBackend { mockContainer.mock(type: ZcashRustBackendWelding.self, isSingleton: true) { _ in rustBackend } @@ -331,7 +364,7 @@ final class BroadcasterTests: ZcashTestCase { ) } - private func makeTransaction(raw: Data, rawID: Data) -> ZcashTransaction.Overview { + private func makeTransaction(raw: Data?, rawID: Data) -> ZcashTransaction.Overview { ZcashTransaction.Overview( accountUUID: TestsData.mockedAccountUUID, blockTime: nil, @@ -447,6 +480,84 @@ private final class StubTransactionEncoder: TransactionEncoder { func closeDBConnection() { } } +private final class LookupTransactionRepository: TransactionRepository, RawTransactionLookup { + private let transaction: ZcashTransaction.Overview + private(set) var receivedRawTransactions: [Data] = [] + + init(transaction: ZcashTransaction.Overview) { + self.transaction = transaction + } + + func closeDBConnection() { } + + func countAll() async throws -> Int { fatalError("Unused in test") } + + func countUnmined() async throws -> Int { fatalError("Unused in test") } + + func isInitialized() async throws -> Bool { fatalError("Unused in test") } + + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] { + fatalError("Unused in test") + } + + func find(rawID: Data) async throws -> ZcashTransaction.Overview { + fatalError("Unused in test") + } + + func find(offset: Int, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func find(in range: CompactBlockRange, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func find(from: ZcashTransaction.Overview, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func findPendingTransactions(latestHeight: BlockHeight, offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func findReceived(offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func findSent(offset: Int, limit: Int) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func findForResubmission(upTo: BlockHeight) async throws -> [ZcashTransaction.Overview] { + fatalError("Unused in test") + } + + func findMemos(for rawID: Data) async throws -> [Memo] { + fatalError("Unused in test") + } + + func findMemos(for transaction: ZcashTransaction.Overview) async throws -> [Memo] { + fatalError("Unused in test") + } + + func getRecipients(for rawID: Data) async throws -> [TransactionRecipient] { + fatalError("Unused in test") + } + + func getTransactionOutputs(for rawID: Data) async throws -> [ZcashTransaction.Output] { + fatalError("Unused in test") + } + + func debugDatabase(sql: String) -> String { + fatalError("Unused in test") + } + + func find(rawTransaction: Data) async throws -> ZcashTransaction.Overview { + receivedRawTransactions.append(rawTransaction) + return transaction + } +} + private final class RecordingCompactTxStreamerService: CompactTxStreamerProvider { var interceptors: CompactTxStreamerServerInterceptorFactoryProtocol? { nil } From c35d0915ee6c3323f2a1f1ddcc7e85e786017b2f Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Tue, 12 May 2026 21:59:45 -0600 Subject: [PATCH 05/16] Validate pending submit plan version --- .../Transaction/PendingSubmitPlanStore.swift | 13 +++++++++- .../PendingSubmitPlanStoreTests.swift | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index 8b24ef8fa..ca5008e60 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -33,6 +33,8 @@ actor PendingSubmitPlanStore { private let logger: Logger private var plansByTransactionId: [String: [StoredEndpoint]] = [:] + // In-memory cache only. After restart, raw transaction submissions recover + // the transaction id through RawTransactionLookup before recording endpoints. private var transactionIdsByRawTransaction: [String: String] = [:] private var loadedFromPersistence = false @@ -150,6 +152,9 @@ actor PendingSubmitPlanStore { } let storedPlans = try JSONDecoder().decode(StoredPlans.self, from: data) + guard storedPlans.version == StoredPlans.currentVersion else { + throw PendingSubmitPlanStoreError.unsupportedVersion(storedPlans.version) + } plansByTransactionId = storedPlans.plansByTransactionId } catch { logger.warn("Failed to load pending submit plans: \(error)") @@ -171,8 +176,10 @@ private struct StoredPlans: Codable { let version: Int let plansByTransactionId: [String: [StoredEndpoint]] + static let currentVersion = 1 + init( - version: Int = 1, + version: Int = Self.currentVersion, plansByTransactionId: [String: [StoredEndpoint]] ) { self.version = version @@ -180,6 +187,10 @@ private struct StoredPlans: Codable { } } +private enum PendingSubmitPlanStoreError: Error { + case unsupportedVersion(Int) +} + private struct StoredEndpoint: Codable, Equatable { let host: String let port: Int diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index c2509bbb3..af1341ed9 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -41,6 +41,32 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { } } + func testIgnoresPersistedSubmitPlansWithUnsupportedVersion() async throws { + let persistence = InMemorySubmitPlanPersistence() + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + persistence.data = """ + { + "version": 2, + "plansByTransactionId": { + "\(transaction.rawID.hexEncodedString())": [ + { + "host": "submit.z.cash", + "port": 443, + "secure": true, + "singleCallTimeoutInMillis": 1000, + "streamingCallTimeoutInMillis": 2000 + } + ] + } + } + """.data(using: .utf8) + + let store = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + let plan = await store.getSubmitPlan(for: transaction.rawID) + + XCTAssertNil(plan) + } + func testAddsSubmittedEndpointsToExistingPlan() async throws { let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) let firstEndpoint = LightWalletEndpoint(address: "a.z.cash", port: 443, secure: true) From 7ec4edb57d614cc269e7c4b4ede42c47994519d4 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 13 May 2026 12:21:33 -0600 Subject: [PATCH 06/16] fix: serialize broadcaster submit plan registration --- .../Block/Actions/TxResubmissionAction.swift | 8 +- .../Synchronizer/SDKBroadcaster.swift | 25 +-- .../Transaction/PendingSubmitPlanStore.swift | 179 ++++++++++++------ Tests/OfflineTests/BroadcasterTests.swift | 94 ++++++++- .../PendingSubmitPlanStoreTests.swift | 94 ++++++++- 5 files changed, 322 insertions(+), 78 deletions(-) diff --git a/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift index d9dfcb59a..4b72a5ac7 100644 --- a/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift +++ b/Sources/ZcashLightClientKit/Block/Actions/TxResubmissionAction.swift @@ -37,8 +37,12 @@ extension TxResubmissionAction: Action { // find all candidates for the resubmission do { logger.info("TxResubmissionAction check started at \(latestBlockHeight) height.") - let transactions = try await transactionRepository.findForResubmission(upTo: latestBlockHeight) - await pendingSubmitPlanStore.retainPlans(for: transactions.map(\.rawID)) + let transactions = try await pendingSubmitPlanStore.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + try await transactionRepository.findForResubmission(upTo: latestBlockHeight) + }, + transactionId: { $0.rawID } + ) // no candidates, update the time and continue with the next action if transactions.isEmpty { diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift index 3908b200d..922286f07 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift @@ -52,12 +52,12 @@ class SDKBroadcaster: Broadcaster { logger: logger ) - let transactions = try await transactionEncoder.createProposedTransactions( - proposal: proposal, - spendingKey: spendingKey - ) - - await pendingSubmitPlanStore.markAwaitingSubmitPlan(transactions) + let transactions = try await pendingSubmitPlanStore.createAndMarkAwaitingSubmitPlan { + try await transactionEncoder.createProposedTransactions( + proposal: proposal, + spendingKey: spendingKey + ) + } sendFoundTransactionsEvent(transactions) return transactions @@ -89,13 +89,14 @@ class SDKBroadcaster: Broadcaster { logger: logger ) - let txId = try await initializer.rustBackend.extractAndStoreTxFromPCZT( - pcztWithProofs: pcztWithProofs, - pcztWithSigs: pcztWithSigs - ) + let transactions = try await pendingSubmitPlanStore.createAndMarkAwaitingSubmitPlan { + let txId = try await initializer.rustBackend.extractAndStoreTxFromPCZT( + pcztWithProofs: pcztWithProofs, + pcztWithSigs: pcztWithSigs + ) - let transactions = try await transactionEncoder.fetchTransactionsForTxIds([txId]) - await pendingSubmitPlanStore.markAwaitingSubmitPlan(transactions) + return try await transactionEncoder.fetchTransactionsForTxIds([txId]) + } sendFoundTransactionsEvent(transactions) return transactions diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index ca5008e60..824b8ef66 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -31,6 +31,7 @@ actor PendingSubmitPlanStore { private let persistence: PendingSubmitPlanPersistence? private let logger: Logger + private let lock = AsyncLock() private var plansByTransactionId: [String: [StoredEndpoint]] = [:] // In-memory cache only. After restart, raw transaction submissions recover @@ -46,83 +47,99 @@ actor PendingSubmitPlanStore { self.logger = logger } - func markAwaitingSubmitPlan(_ transactions: [ZcashTransaction.Overview]) { - loadFromPersistenceIfNeeded() - - var changed = false - for transaction in transactions { - let transactionId = transaction.rawID.stablePlanKey - if let raw = transaction.raw { - transactionIdsByRawTransaction[raw.stablePlanKey] = transactionId - } - if plansByTransactionId[transactionId] == nil { - plansByTransactionId[transactionId] = [] - changed = true - } - } - - if changed { - saveToPersistence() + func createAndMarkAwaitingSubmitPlan( + createTransactions: () async throws -> [ZcashTransaction.Overview] + ) async rethrows -> [ZcashTransaction.Overview] { + try await lock.withLock { + loadFromPersistenceIfNeeded() + let transactions = try await createTransactions() + markAwaitingSubmitPlanWithoutLock(transactions) + return transactions } } func addSubmitEndpoint( rawTransaction: Data, endpoint: LightWalletEndpoint - ) { - loadFromPersistenceIfNeeded() + ) async { + await lock.withLock { + loadFromPersistenceIfNeeded() - guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { - return - } + guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { + return + } - addSubmitEndpoint(transactionId: transactionId, endpoint: endpoint) + addSubmitEndpoint(transactionId: transactionId, endpoint: endpoint) + } } func addSubmitEndpoint( transaction: ZcashTransaction.Overview, endpoint: LightWalletEndpoint - ) { - loadFromPersistenceIfNeeded() - if let raw = transaction.raw { - transactionIdsByRawTransaction[raw.stablePlanKey] = transaction.rawID.stablePlanKey + ) async { + await lock.withLock { + loadFromPersistenceIfNeeded() + if let raw = transaction.raw { + transactionIdsByRawTransaction[raw.stablePlanKey] = transaction.rawID.stablePlanKey + } + addSubmitEndpoint(transactionId: transaction.rawID.stablePlanKey, endpoint: endpoint) } - addSubmitEndpoint(transactionId: transaction.rawID.stablePlanKey, endpoint: endpoint) } - func getSubmitPlan(for transactionId: Data) -> StoredSubmitPlan? { - loadFromPersistenceIfNeeded() - - switch plansByTransactionId[transactionId.stablePlanKey] { - case nil: - return nil - case let endpoints? where endpoints.isEmpty: - return .awaitingPlan - case let endpoints?: - return .ready(TransactionSubmitPlan(endpoints: endpoints.map(\.endpoint))) + func getSubmitPlan(for transactionId: Data) async -> StoredSubmitPlan? { + await lock.withLock { + loadFromPersistenceIfNeeded() + + switch plansByTransactionId[transactionId.stablePlanKey] { + case nil: + return nil + case let endpoints? where endpoints.isEmpty: + return .awaitingPlan + case let endpoints?: + return .ready(TransactionSubmitPlan(endpoints: endpoints.map(\.endpoint))) + } } } - func retainPlans(for transactionIds: [Data]) { - loadFromPersistenceIfNeeded() - - let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) - let previousPlanCount = plansByTransactionId.count - plansByTransactionId = plansByTransactionId.filter { retainedTransactionIds.contains($0.key) } - transactionIdsByRawTransaction = transactionIdsByRawTransaction.filter { retainedTransactionIds.contains($0.value) } + func loadTransactionsAndRetainSubmitPlans( + loadTransactions: () async throws -> [T], + transactionId: (T) -> Data + ) async rethrows -> [T] { + try await lock.withLock { + loadFromPersistenceIfNeeded() + let transactions = try await loadTransactions() + retainPlansWithoutLock(for: transactions.map(transactionId)) + return transactions + } + } - if plansByTransactionId.count != previousPlanCount { - saveToPersistence() + func clear() async { + await lock.withLock { + plansByTransactionId.removeAll() + transactionIdsByRawTransaction.removeAll() + do { + try persistence?.clear() + } catch { + logger.warn("Failed to clear pending submit plans: \(error)") + } } } - func clear() { - plansByTransactionId.removeAll() - transactionIdsByRawTransaction.removeAll() - do { - try persistence?.clear() - } catch { - logger.warn("Failed to clear pending submit plans: \(error)") + private func markAwaitingSubmitPlanWithoutLock(_ transactions: [ZcashTransaction.Overview]) { + var changed = false + for transaction in transactions { + let transactionId = transaction.rawID.stablePlanKey + if let raw = transaction.raw { + transactionIdsByRawTransaction[raw.stablePlanKey] = transactionId + } + if plansByTransactionId[transactionId] == nil { + plansByTransactionId[transactionId] = [] + changed = true + } + } + + if changed { + saveToPersistence() } } @@ -139,6 +156,17 @@ actor PendingSubmitPlanStore { saveToPersistence() } + private func retainPlansWithoutLock(for transactionIds: [Data]) { + let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) + let previousPlanCount = plansByTransactionId.count + plansByTransactionId = plansByTransactionId.filter { retainedTransactionIds.contains($0.key) } + transactionIdsByRawTransaction = transactionIdsByRawTransaction.filter { retainedTransactionIds.contains($0.value) } + + if plansByTransactionId.count != previousPlanCount { + saveToPersistence() + } + } + private func loadFromPersistenceIfNeeded() { guard !loadedFromPersistence else { return } defer { loadedFromPersistence = true } @@ -221,6 +249,49 @@ private extension Data { var stablePlanKey: String { hexEncodedString() } } +private final class AsyncLock { + private let state = State() + + func withLock(_ operation: () async throws -> T) async rethrows -> T { + await state.acquire() + do { + let result = try await operation() + await state.release() + return result + } catch { + await state.release() + throw error + } + } + + private actor State { + private var isLocked = false + private var waiters: [CheckedContinuation] = [] + + func acquire() async { + if !isLocked { + isLocked = true + return + } + + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + func release() { + precondition(isLocked, "Attempted to release an unlocked AsyncLock.") + guard !waiters.isEmpty else { + isLocked = false + return + } + + let continuation = waiters.removeFirst() + continuation.resume() + } + } +} + struct KeychainSubmitPlanPersistence: PendingSubmitPlanPersistence { private enum Constants { static let service = "cash.z.ecc.ZcashLightClientKit.pending-submit-plans" diff --git a/Tests/OfflineTests/BroadcasterTests.swift b/Tests/OfflineTests/BroadcasterTests.swift index ae0c8eeeb..8cd4156e2 100644 --- a/Tests/OfflineTests/BroadcasterTests.swift +++ b/Tests/OfflineTests/BroadcasterTests.swift @@ -71,6 +71,57 @@ final class BroadcasterTests: ZcashTestCase { await fulfillment(of: [foundTransactionsExpectation], timeout: 1.0) } + func testCreateProposedTransactionsMarksPendingPlanBeforeStoreReadsProceed() async throws { + let rawID = Data(repeating: 0xAB, count: 32) + let createdTransactions = [makeTransaction(raw: Data([0x01, 0x02]), rawID: rawID)] + let createStarted = AsyncSignal() + let allowCreateToReturn = AsyncSignal() + let retainCompleted = AsyncFlag() + let transactionEncoder = StubTransactionEncoder(createdTransactions: createdTransactions) { + await createStarted.signal() + await allowCreateToReturn.wait() + } + let pendingSubmitPlanStore = PendingSubmitPlanStore(logger: NullLogger()) + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + pendingSubmitPlanStore: pendingSubmitPlanStore + ) + + await synchronizer.updateStatus(.stopped) + + let createTask = Task { + try await synchronizer.broadcaster.createProposedTransactions( + proposal: Proposal.testOnlyFakeProposal(totalFee: 10), + spendingKey: TestsData(networkType: .testnet).spendingKey + ) + } + await createStarted.wait() + + let retainTask = Task { + _ = await pendingSubmitPlanStore.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { createdTransactions }, + transactionId: { $0.rawID } + ) + await retainCompleted.set() + } + + try await Task.sleep(nanoseconds: 20_000_000) + let didRetainComplete = await retainCompleted.get() + XCTAssertFalse(didRetainComplete) + + await allowCreateToReturn.signal() + + let transactions = try await createTask.value + await retainTask.value + XCTAssertEqual(transactions.map(\.rawID), [rawID]) + switch await pendingSubmitPlanStore.getSubmitPlan(for: rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected created broadcaster transaction to wait for a submit plan.") + } + } + // MARK: - submit func testSubmitSendsRawBytesToProvidedEndpoint() async throws { @@ -149,7 +200,7 @@ final class BroadcasterTests: ZcashTestCase { transactionRepository: transactionRepository ) - await pendingSubmitPlanStore.markAwaitingSubmitPlan([storedTransaction]) + _ = await pendingSubmitPlanStore.createAndMarkAwaitingSubmitPlan { [storedTransaction] } try await synchronizer.broadcaster.submit(rawTransaction, to: service.endpoint) @@ -425,12 +476,17 @@ final class BroadcasterTests: ZcashTestCase { private final class StubTransactionEncoder: TransactionEncoder { private let createdTransactions: [ZcashTransaction.Overview] + private let beforeReturningCreatedTransactions: () async -> Void private(set) var receivedCreateArguments: (proposal: Proposal, spendingKey: UnifiedSpendingKey)? private(set) var receivedFetchTxIds: [Data]? private(set) var submittedTransactions: [EncodedTransaction] = [] - init(createdTransactions: [ZcashTransaction.Overview]) { + init( + createdTransactions: [ZcashTransaction.Overview], + beforeReturningCreatedTransactions: @escaping () async -> Void = {} + ) { self.createdTransactions = createdTransactions + self.beforeReturningCreatedTransactions = beforeReturningCreatedTransactions } func proposeTransfer( @@ -456,6 +512,7 @@ private final class StubTransactionEncoder: TransactionEncoder { spendingKey: UnifiedSpendingKey ) async throws -> [ZcashTransaction.Overview] { receivedCreateArguments = (proposal, spendingKey) + await beforeReturningCreatedTransactions() return createdTransactions } @@ -558,6 +615,39 @@ private final class LookupTransactionRepository: TransactionRepository, RawTrans } } +private actor AsyncSignal { + private var isSignaled = false + private var continuation: CheckedContinuation? + + func signal() { + isSignaled = true + continuation?.resume() + continuation = nil + } + + func wait() async { + if isSignaled { + return + } + + await withCheckedContinuation { continuation in + self.continuation = continuation + } + } +} + +private actor AsyncFlag { + private var value = false + + func set() { + value = true + } + + func get() -> Bool { + value + } +} + private final class RecordingCompactTxStreamerService: CompactTxStreamerProvider { var interceptors: CompactTxStreamerServerInterceptorFactoryProtocol? { nil } diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index af1341ed9..d54a30d0f 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -12,7 +12,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) let store = PendingSubmitPlanStore(logger: NullLogger()) - await store.markAwaitingSubmitPlan([transaction]) + await markAwaiting([transaction], in: store) switch await store.getSubmitPlan(for: transaction.rawID) { case .awaitingPlan: @@ -28,7 +28,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let endpoint = LightWalletEndpointBuilder.default let firstStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) - await firstStore.markAwaitingSubmitPlan([transaction]) + await markAwaiting([transaction], in: firstStore) await firstStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) let secondStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) @@ -73,7 +73,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let secondEndpoint = LightWalletEndpoint(address: "b.z.cash", port: 443, secure: true) let store = PendingSubmitPlanStore(logger: NullLogger()) - await store.markAwaitingSubmitPlan([transaction]) + await markAwaiting([transaction], in: store) await store.addSubmitEndpoint(transaction: transaction, endpoint: firstEndpoint) await store.addSubmitEndpoint(transaction: transaction, endpoint: secondEndpoint) @@ -93,7 +93,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let endpoint = LightWalletEndpoint(address: "submit.z.cash", port: 443, secure: true) let store = PendingSubmitPlanStore(logger: NullLogger()) - await store.markAwaitingSubmitPlan([transaction]) + await markAwaiting([transaction], in: store) await store.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) switch await store.getSubmitPlan(for: transaction.rawID) { @@ -118,10 +118,10 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { ) let store = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) - await store.markAwaitingSubmitPlan([retainedTransaction, prunedTransaction]) + await markAwaiting([retainedTransaction, prunedTransaction], in: store) await store.addSubmitEndpoint(transaction: retainedTransaction, endpoint: LightWalletEndpointBuilder.default) await store.addSubmitEndpoint(transaction: prunedTransaction, endpoint: LightWalletEndpointBuilder.eccTestnet) - await store.retainPlans(for: [retainedTransaction.rawID]) + await loadAndRetain([retainedTransaction], in: store) await store.addSubmitEndpoint(rawTransaction: prunedRawTransaction, endpoint: LightWalletEndpointBuilder.eccTestnet) let reloadedStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) @@ -152,7 +152,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { } mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } - await store.markAwaitingSubmitPlan([awaitingTransaction]) + await markAwaiting([awaitingTransaction], in: store) let action = TxResubmissionAction(container: mockContainer) action.latestResolvedTime = 0 @@ -180,7 +180,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { } mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } - await store.markAwaitingSubmitPlan([transaction]) + await markAwaiting([transaction], in: store) await store.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) let action = TxResubmissionAction(container: mockContainer) @@ -192,6 +192,53 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { assertEndpoint(try XCTUnwrap(submitter.submissions.first?.endpoint), equals: endpoint) } + func testTxResubmissionDoesNotPrunePlanCreatedAfterCandidateLookup() async throws { + let createdTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let transactionRepository = TransactionRepositoryMock() + let transactionEncoder = RecordingTransactionEncoder() + let store = PendingSubmitPlanStore(logger: NullLogger()) + let submitter = RecordingTransactionSubmitter() + let createCompleted = AsyncFlag() + var createTask: Task? + + transactionRepository.findForResubmissionUpToClosure = { _ in + createTask = Task { + await self.markAwaiting([createdTransaction], in: store) + await createCompleted.set() + } + + try await Task.sleep(nanoseconds: 20_000_000) + let didCreateComplete = await createCompleted.get() + XCTAssertFalse(didCreateComplete) + return [] + } + + mockContainer.mock(type: TransactionRepository.self, isSingleton: true) { _ in transactionRepository } + mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } + mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } + mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in + SubmitPlanExecutor(transactionSubmitter: submitter) + } + mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } + + let action = TxResubmissionAction(container: mockContainer) + action.latestResolvedTime = 0 + _ = try await action.run(with: resubmissionContext()) { _ in } + + guard let createTask else { + XCTFail("Expected test to start concurrent transaction creation.") + return + } + await createTask.value + + switch await store.getSubmitPlan(for: createdTransaction.rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected concurrent broadcaster transaction plan to survive pruning.") + } + } + private func resubmissionContext() -> ActionContextMock { let context = ActionContextMock.default() context.underlyingSyncControlData = SyncControlData( @@ -202,6 +249,25 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { return context } + @discardableResult + private func markAwaiting( + _ transactions: [ZcashTransaction.Overview], + in store: PendingSubmitPlanStore + ) async -> [ZcashTransaction.Overview] { + await store.createAndMarkAwaitingSubmitPlan { transactions } + } + + @discardableResult + private func loadAndRetain( + _ transactions: [ZcashTransaction.Overview], + in store: PendingSubmitPlanStore + ) async -> [ZcashTransaction.Overview] { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { transactions }, + transactionId: { $0.rawID } + ) + } + private func makeTransaction( rawID: Data, raw: Data = Data([0x01, 0x02]) @@ -335,3 +401,15 @@ private final class RecordingTransactionSubmitter: TransactionSubmitter { submissions.append(Submission(transaction: transaction, endpoint: endpoint)) } } + +private actor AsyncFlag { + private var value = false + + func set() { + value = true + } + + func get() -> Bool { + value + } +} From bd3a313d2fdefd075de85b2043df7dd194034e19 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 20 May 2026 10:45:40 -0600 Subject: [PATCH 07/16] fix: avoid submit plan overwrite after load failure --- .../Transaction/PendingSubmitPlanStore.swift | 29 +++++++++++++++++-- .../PendingSubmitPlanStoreTests.swift | 26 ++++++++++++++++- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index 824b8ef66..15bf8b822 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -169,13 +169,13 @@ actor PendingSubmitPlanStore { private func loadFromPersistenceIfNeeded() { guard !loadedFromPersistence else { return } - defer { loadedFromPersistence = true } do { guard let data = try persistence?.load(), !data.isEmpty else { + loadedFromPersistence = true return } @@ -183,13 +183,38 @@ actor PendingSubmitPlanStore { guard storedPlans.version == StoredPlans.currentVersion else { throw PendingSubmitPlanStoreError.unsupportedVersion(storedPlans.version) } - plansByTransactionId = storedPlans.plansByTransactionId + mergeLoadedPlans(storedPlans.plansByTransactionId) + loadedFromPersistence = true } catch { logger.warn("Failed to load pending submit plans: \(error)") } } + private func mergeLoadedPlans(_ loadedPlans: [String: [StoredEndpoint]]) { + guard !plansByTransactionId.isEmpty else { + plansByTransactionId = loadedPlans + return + } + + var mergedPlans = loadedPlans + for (transactionId, currentEndpoints) in plansByTransactionId { + var endpoints = mergedPlans[transactionId] ?? [] + currentEndpoints.forEach { + if !endpoints.contains($0) { + endpoints.append($0) + } + } + mergedPlans[transactionId] = endpoints + } + plansByTransactionId = mergedPlans + } + private func saveToPersistence() { + guard loadedFromPersistence else { + logger.warn("Skipping pending submit plan save because persisted plans have not loaded.") + return + } + do { let storedPlans = StoredPlans(plansByTransactionId: plansByTransactionId) let data = try JSONEncoder().encode(storedPlans) diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index d54a30d0f..af6789e83 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -67,6 +67,20 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { XCTAssertNil(plan) } + func testDoesNotOverwritePersistedSubmitPlansWhenLoadFails() async throws { + let persistence = InMemorySubmitPlanPersistence() + let originalData = Data("persisted submit plans".utf8) + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + persistence.data = originalData + persistence.loadError = SubmitPlanPersistenceTestError.loadFailed + let store = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + + await markAwaiting([transaction], in: store) + + XCTAssertEqual(persistence.saveCallCount, 0) + XCTAssertEqual(persistence.data, originalData) + } + func testAddsSubmittedEndpointsToExistingPlan() async throws { let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) let firstEndpoint = LightWalletEndpoint(address: "a.z.cash", port: 443, secure: true) @@ -319,12 +333,18 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { private final class InMemorySubmitPlanPersistence: PendingSubmitPlanPersistence { var data: Data? + var loadError: Error? + private(set) var saveCallCount = 0 func load() throws -> Data? { - data + if let loadError { + throw loadError + } + return data } func save(_ data: Data) throws { + saveCallCount += 1 self.data = data } @@ -333,6 +353,10 @@ private final class InMemorySubmitPlanPersistence: PendingSubmitPlanPersistence } } +private enum SubmitPlanPersistenceTestError: Error { + case loadFailed +} + private final class RecordingTransactionEncoder: TransactionEncoder { private(set) var submittedTransactions: [EncodedTransaction] = [] From e749fec0317c03b1da5bc5f98435470fc9f82dcf Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 20 May 2026 10:59:23 -0600 Subject: [PATCH 08/16] fix: log submit plan endpoint failures --- .../Synchronizer/Dependencies.swift | 5 ++++- .../Transaction/SubmitPlanExecutor.swift | 10 +++++++++- Tests/OfflineTests/PendingSubmitPlanStoreTests.swift | 6 +++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift index 68f20a58b..e0d7e6f2f 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/Dependencies.swift @@ -168,7 +168,10 @@ enum Dependencies { } container.register(type: SubmitPlanExecutor.self, isSingleton: true) { di in - SubmitPlanExecutor(transactionSubmitter: di.resolve(TransactionSubmitter.self)) + SubmitPlanExecutor( + transactionSubmitter: di.resolve(TransactionSubmitter.self), + logger: di.resolve(Logger.self) + ) } container.register(type: TransactionEncoder.self, isSingleton: true) { di in diff --git a/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift b/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift index 627c8a2ec..bf914a088 100644 --- a/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift +++ b/Sources/ZcashLightClientKit/Transaction/SubmitPlanExecutor.swift @@ -87,9 +87,14 @@ final class EndpointTransactionSubmitter: TransactionSubmitter { final class SubmitPlanExecutor { private let transactionSubmitter: TransactionSubmitter + private let logger: Logger - init(transactionSubmitter: TransactionSubmitter) { + init( + transactionSubmitter: TransactionSubmitter, + logger: Logger + ) { self.transactionSubmitter = transactionSubmitter + self.logger = logger } func submit( @@ -103,6 +108,9 @@ final class SubmitPlanExecutor { try await transactionSubmitter.submit(transaction: transaction, to: endpoint) return } catch { + logger.warn( + "Submit plan endpoint \(endpoint.host):\(endpoint.port) failed: \(error)" + ) lastError = error } } diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index af6789e83..dd2c751bd 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -162,7 +162,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in - SubmitPlanExecutor(transactionSubmitter: submitter) + SubmitPlanExecutor(transactionSubmitter: submitter, logger: NullLogger()) } mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } @@ -190,7 +190,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in - SubmitPlanExecutor(transactionSubmitter: submitter) + SubmitPlanExecutor(transactionSubmitter: submitter, logger: NullLogger()) } mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } @@ -231,7 +231,7 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { mockContainer.mock(type: TransactionEncoder.self, isSingleton: true) { _ in transactionEncoder } mockContainer.mock(type: PendingSubmitPlanStore.self, isSingleton: true) { _ in store } mockContainer.mock(type: SubmitPlanExecutor.self, isSingleton: true) { _ in - SubmitPlanExecutor(transactionSubmitter: submitter) + SubmitPlanExecutor(transactionSubmitter: submitter, logger: NullLogger()) } mockContainer.mock(type: Logger.self, isSingleton: true) { _ in NullLogger() } From e236ddf32c4fb02d3edf6fa7d99e091f9df86d7f Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 20 May 2026 11:54:36 -0600 Subject: [PATCH 09/16] Remove pending submit plan async lock --- .../Transaction/PendingSubmitPlanStore.swift | 214 ++++++++------- Tests/OfflineTests/BroadcasterTests.swift | 19 +- .../PendingSubmitPlanStoreTests.swift | 250 ++++++++++++++++-- 3 files changed, 362 insertions(+), 121 deletions(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index 15bf8b822..85d826bcc 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -29,15 +29,32 @@ actor PendingSubmitPlanStore { case ready(TransactionSubmitPlan) } + private struct RetainToken { + let storeRevision: UInt64 + let activeSubmitPlanCreations: Int + } + + private struct SubmitPlanCreationToken { + let clearGeneration: UInt64 + } + private let persistence: PendingSubmitPlanPersistence? private let logger: Logger - private let lock = AsyncLock() private var plansByTransactionId: [String: [StoredEndpoint]] = [:] // In-memory cache only. After restart, raw transaction submissions recover // the transaction id through RawTransactionLookup before recording endpoints. private var transactionIdsByRawTransaction: [String: String] = [:] private var loadedFromPersistence = false + // Actor isolation prevents data races, but actor methods are reentrant + // across await. Candidate lookup returns a repository snapshot, so pruning + // only applies if no submit-plan creation was active and the store did not + // change while that lookup was suspended. + private var activeSubmitPlanCreations = 0 + private var storeRevision: UInt64 = 0 + // Prevents a creation that started before clear() from recording plans after + // clear() has removed the in-memory and persisted submit-plan state. + private var clearGeneration: UInt64 = 0 init( persistence: PendingSubmitPlanPersistence? = nil, @@ -50,54 +67,55 @@ actor PendingSubmitPlanStore { func createAndMarkAwaitingSubmitPlan( createTransactions: () async throws -> [ZcashTransaction.Overview] ) async rethrows -> [ZcashTransaction.Overview] { - try await lock.withLock { - loadFromPersistenceIfNeeded() - let transactions = try await createTransactions() - markAwaitingSubmitPlanWithoutLock(transactions) - return transactions + loadFromPersistenceIfNeeded() + let creationToken = beginSubmitPlanCreation() + defer { endSubmitPlanCreation() } + + let transactions = try await createTransactions() + if canRecordSubmitPlanCreation(using: creationToken) { + markAwaitingSubmitPlan(transactions) } + return transactions } func addSubmitEndpoint( rawTransaction: Data, endpoint: LightWalletEndpoint ) async { - await lock.withLock { - loadFromPersistenceIfNeeded() - - guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { - return - } + loadFromPersistenceIfNeeded() - addSubmitEndpoint(transactionId: transactionId, endpoint: endpoint) + guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { + return } + + addSubmitEndpoint(transactionId: transactionId, endpoint: endpoint) } func addSubmitEndpoint( transaction: ZcashTransaction.Overview, endpoint: LightWalletEndpoint ) async { - await lock.withLock { - loadFromPersistenceIfNeeded() - if let raw = transaction.raw { - transactionIdsByRawTransaction[raw.stablePlanKey] = transaction.rawID.stablePlanKey + loadFromPersistenceIfNeeded() + if let raw = transaction.raw { + let transactionId = transaction.rawID.stablePlanKey + if transactionIdsByRawTransaction[raw.stablePlanKey] != transactionId { + transactionIdsByRawTransaction[raw.stablePlanKey] = transactionId + bumpStoreRevision() } - addSubmitEndpoint(transactionId: transaction.rawID.stablePlanKey, endpoint: endpoint) } + addSubmitEndpoint(transactionId: transaction.rawID.stablePlanKey, endpoint: endpoint) } func getSubmitPlan(for transactionId: Data) async -> StoredSubmitPlan? { - await lock.withLock { - loadFromPersistenceIfNeeded() - - switch plansByTransactionId[transactionId.stablePlanKey] { - case nil: - return nil - case let endpoints? where endpoints.isEmpty: - return .awaitingPlan - case let endpoints?: - return .ready(TransactionSubmitPlan(endpoints: endpoints.map(\.endpoint))) - } + loadFromPersistenceIfNeeded() + + switch plansByTransactionId[transactionId.stablePlanKey] { + case nil: + return nil + case let endpoints? where endpoints.isEmpty: + return .awaitingPlan + case let endpoints?: + return .ready(TransactionSubmitPlan(endpoints: endpoints.map(\.endpoint))) } } @@ -105,40 +123,51 @@ actor PendingSubmitPlanStore { loadTransactions: () async throws -> [T], transactionId: (T) -> Data ) async rethrows -> [T] { - try await lock.withLock { - loadFromPersistenceIfNeeded() - let transactions = try await loadTransactions() - retainPlansWithoutLock(for: transactions.map(transactionId)) - return transactions + loadFromPersistenceIfNeeded() + let retainToken = makeRetainToken() + + let transactions = try await loadTransactions() + if canApplyRetain(using: retainToken) { + retainPlans(for: transactions.map(transactionId)) } + return transactions } func clear() async { - await lock.withLock { - plansByTransactionId.removeAll() - transactionIdsByRawTransaction.removeAll() - do { - try persistence?.clear() - } catch { - logger.warn("Failed to clear pending submit plans: \(error)") - } + plansByTransactionId.removeAll() + transactionIdsByRawTransaction.removeAll() + clearGeneration &+= 1 + bumpStoreRevision() + do { + try persistence?.clear() + } catch { + logger.warn("Failed to clear pending submit plans: \(error)") } } - private func markAwaitingSubmitPlanWithoutLock(_ transactions: [ZcashTransaction.Overview]) { - var changed = false + private func markAwaitingSubmitPlan(_ transactions: [ZcashTransaction.Overview]) { + var shouldSave = false + var didChange = false for transaction in transactions { let transactionId = transaction.rawID.stablePlanKey if let raw = transaction.raw { - transactionIdsByRawTransaction[raw.stablePlanKey] = transactionId + let rawTransactionKey = raw.stablePlanKey + if transactionIdsByRawTransaction[rawTransactionKey] != transactionId { + transactionIdsByRawTransaction[rawTransactionKey] = transactionId + didChange = true + } } if plansByTransactionId[transactionId] == nil { plansByTransactionId[transactionId] = [] - changed = true + shouldSave = true + didChange = true } } - if changed { + if didChange { + bumpStoreRevision() + } + if shouldSave { saveToPersistence() } } @@ -153,20 +182,59 @@ actor PendingSubmitPlanStore { endpoints.append(storedEndpoint) plansByTransactionId[transactionId] = endpoints + bumpStoreRevision() saveToPersistence() } - private func retainPlansWithoutLock(for transactionIds: [Data]) { + private func retainPlans(for transactionIds: [Data]) { let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) let previousPlanCount = plansByTransactionId.count + let previousRawTransactionCount = transactionIdsByRawTransaction.count plansByTransactionId = plansByTransactionId.filter { retainedTransactionIds.contains($0.key) } transactionIdsByRawTransaction = transactionIdsByRawTransaction.filter { retainedTransactionIds.contains($0.value) } + if plansByTransactionId.count != previousPlanCount || + transactionIdsByRawTransaction.count != previousRawTransactionCount { + bumpStoreRevision() + } + if plansByTransactionId.count != previousPlanCount { saveToPersistence() } } + private func beginSubmitPlanCreation() -> SubmitPlanCreationToken { + activeSubmitPlanCreations += 1 + bumpStoreRevision() + return SubmitPlanCreationToken(clearGeneration: clearGeneration) + } + + private func endSubmitPlanCreation() { + precondition(activeSubmitPlanCreations > 0, "Attempted to end inactive submit-plan creation.") + activeSubmitPlanCreations -= 1 + } + + private func makeRetainToken() -> RetainToken { + RetainToken( + storeRevision: storeRevision, + activeSubmitPlanCreations: activeSubmitPlanCreations + ) + } + + private func canApplyRetain(using token: RetainToken) -> Bool { + token.activeSubmitPlanCreations == 0 && + activeSubmitPlanCreations == 0 && + storeRevision == token.storeRevision + } + + private func canRecordSubmitPlanCreation(using token: SubmitPlanCreationToken) -> Bool { + clearGeneration == token.clearGeneration + } + + private func bumpStoreRevision() { + storeRevision &+= 1 + } + private func loadFromPersistenceIfNeeded() { guard !loadedFromPersistence else { return } @@ -191,8 +259,12 @@ actor PendingSubmitPlanStore { } private func mergeLoadedPlans(_ loadedPlans: [String: [StoredEndpoint]]) { + let previousPlans = plansByTransactionId guard !plansByTransactionId.isEmpty else { plansByTransactionId = loadedPlans + if previousPlans != plansByTransactionId { + bumpStoreRevision() + } return } @@ -207,6 +279,9 @@ actor PendingSubmitPlanStore { mergedPlans[transactionId] = endpoints } plansByTransactionId = mergedPlans + if previousPlans != plansByTransactionId { + bumpStoreRevision() + } } private func saveToPersistence() { @@ -274,49 +349,6 @@ private extension Data { var stablePlanKey: String { hexEncodedString() } } -private final class AsyncLock { - private let state = State() - - func withLock(_ operation: () async throws -> T) async rethrows -> T { - await state.acquire() - do { - let result = try await operation() - await state.release() - return result - } catch { - await state.release() - throw error - } - } - - private actor State { - private var isLocked = false - private var waiters: [CheckedContinuation] = [] - - func acquire() async { - if !isLocked { - isLocked = true - return - } - - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - func release() { - precondition(isLocked, "Attempted to release an unlocked AsyncLock.") - guard !waiters.isEmpty else { - isLocked = false - return - } - - let continuation = waiters.removeFirst() - continuation.resume() - } - } -} - struct KeychainSubmitPlanPersistence: PendingSubmitPlanPersistence { private enum Constants { static let service = "cash.z.ecc.ZcashLightClientKit.pending-submit-plans" diff --git a/Tests/OfflineTests/BroadcasterTests.swift b/Tests/OfflineTests/BroadcasterTests.swift index 8cd4156e2..f32438de3 100644 --- a/Tests/OfflineTests/BroadcasterTests.swift +++ b/Tests/OfflineTests/BroadcasterTests.swift @@ -71,12 +71,11 @@ final class BroadcasterTests: ZcashTestCase { await fulfillment(of: [foundTransactionsExpectation], timeout: 1.0) } - func testCreateProposedTransactionsMarksPendingPlanBeforeStoreReadsProceed() async throws { + func testCreateProposedTransactionsKeepsPlanWhenRetainRunsDuringCreation() async throws { let rawID = Data(repeating: 0xAB, count: 32) let createdTransactions = [makeTransaction(raw: Data([0x01, 0x02]), rawID: rawID)] let createStarted = AsyncSignal() let allowCreateToReturn = AsyncSignal() - let retainCompleted = AsyncFlag() let transactionEncoder = StubTransactionEncoder(createdTransactions: createdTransactions) { await createStarted.signal() await allowCreateToReturn.wait() @@ -97,22 +96,14 @@ final class BroadcasterTests: ZcashTestCase { } await createStarted.wait() - let retainTask = Task { - _ = await pendingSubmitPlanStore.loadTransactionsAndRetainSubmitPlans( - loadTransactions: { createdTransactions }, - transactionId: { $0.rawID } - ) - await retainCompleted.set() - } - - try await Task.sleep(nanoseconds: 20_000_000) - let didRetainComplete = await retainCompleted.get() - XCTAssertFalse(didRetainComplete) + _ = await pendingSubmitPlanStore.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { [ZcashTransaction.Overview]() }, + transactionId: { $0.rawID } + ) await allowCreateToReturn.signal() let transactions = try await createTask.value - await retainTask.value XCTAssertEqual(transactions.map(\.rawID), [rawID]) switch await pendingSubmitPlanStore.getSubmitPlan(for: rawID) { case .awaitingPlan: diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index dd2c751bd..d54e43f2f 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -145,6 +145,205 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { XCTAssertNil(prunedPlan) } + func testRetainSkipsPruneWhenCreationCompletesDuringCandidateLookup() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let lookupStarted = AsyncSignal() + let allowLookupToReturn = AsyncSignal() + + let retainTask = Task { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + await lookupStarted.signal() + await allowLookupToReturn.wait() + return [ZcashTransaction.Overview]() + }, + transactionId: { $0.rawID } + ) + } + await lookupStarted.wait() + + await markAwaiting([transaction], in: store) + await allowLookupToReturn.signal() + _ = await retainTask.value + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected submit plan created during candidate lookup to survive stale pruning.") + } + } + + func testRetainSkipsPruneWhenCreationWasAlreadyActiveDuringCandidateLookup() async throws { + let existingTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let creationStarted = AsyncSignal() + let allowCreationToReturn = AsyncSignal() + let lookupStarted = AsyncSignal() + let allowLookupToReturn = AsyncSignal() + + await markAwaiting([existingTransaction], in: store) + + let creationTask = Task { + await store.createAndMarkAwaitingSubmitPlan { + await creationStarted.signal() + await allowCreationToReturn.wait() + return [] + } + } + await creationStarted.wait() + + let retainTask = Task { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + await lookupStarted.signal() + await allowLookupToReturn.wait() + return [ZcashTransaction.Overview]() + }, + transactionId: { $0.rawID } + ) + } + await lookupStarted.wait() + + await allowCreationToReturn.signal() + _ = await creationTask.value + await allowLookupToReturn.signal() + _ = await retainTask.value + + switch await store.getSubmitPlan(for: existingTransaction.rawID) { + case .awaitingPlan: + break + default: + XCTFail("Expected stale pruning to be skipped when lookup started during transaction creation.") + } + } + + func testEndpointRegistrationDuringCandidateLookupInvalidatesPrune() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let endpoint = LightWalletEndpoint(address: "submit.z.cash", port: 443, secure: true) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let lookupStarted = AsyncSignal() + let allowLookupToReturn = AsyncSignal() + + let retainTask = Task { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + await lookupStarted.signal() + await allowLookupToReturn.wait() + return [ZcashTransaction.Overview]() + }, + transactionId: { $0.rawID } + ) + } + await lookupStarted.wait() + + await store.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) + await allowLookupToReturn.signal() + _ = await retainTask.value + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: endpoint) + default: + XCTFail("Expected endpoint registration during candidate lookup to invalidate stale pruning.") + } + + await loadAndRetain([], in: store) + let planAfterCleanRetain = await store.getSubmitPlan(for: transaction.rawID) + XCTAssertNil(planAfterCleanRetain) + } + + func testOverlappingRetainsDoNotBothPruneFromStaleSnapshots() async throws { + let firstTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let secondTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let firstLookupStarted = AsyncSignal() + let secondLookupStarted = AsyncSignal() + let allowFirstLookupToReturn = AsyncSignal() + let allowSecondLookupToReturn = AsyncSignal() + + await markAwaiting([firstTransaction, secondTransaction], in: store) + + let firstRetainTask = Task { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + await firstLookupStarted.signal() + await allowFirstLookupToReturn.wait() + return [firstTransaction] + }, + transactionId: { $0.rawID } + ) + } + let secondRetainTask = Task { + await store.loadTransactionsAndRetainSubmitPlans( + loadTransactions: { + await secondLookupStarted.signal() + await allowSecondLookupToReturn.wait() + return [secondTransaction] + }, + transactionId: { $0.rawID } + ) + } + await firstLookupStarted.wait() + await secondLookupStarted.wait() + + await allowFirstLookupToReturn.signal() + _ = await firstRetainTask.value + await allowSecondLookupToReturn.signal() + _ = await secondRetainTask.value + + let firstPlan = await store.getSubmitPlan(for: firstTransaction.rawID) + let secondPlan = await store.getSubmitPlan(for: secondTransaction.rawID) + XCTAssertNotNil(firstPlan) + XCTAssertNil(secondPlan) + } + + func testThrowingCreationDoesNotSuppressFuturePruning() async throws { + let staleTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + + await markAwaiting([staleTransaction], in: store) + + do { + _ = try await store.createAndMarkAwaitingSubmitPlan { + throw CancellationError() + } + XCTFail("Expected transaction creation to throw.") + } catch is CancellationError { + // Expected. + } + + await loadAndRetain([], in: store) + + let plan = await store.getSubmitPlan(for: staleTransaction.rawID) + XCTAssertNil(plan) + } + + func testClearDuringCreationPreventsPlanFromBeingRecorded() async throws { + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let creationStarted = AsyncSignal() + let allowCreationToReturn = AsyncSignal() + + let creationTask = Task { + await store.createAndMarkAwaitingSubmitPlan { + await creationStarted.signal() + await allowCreationToReturn.wait() + return [transaction] + } + } + await creationStarted.wait() + + await store.clear() + await allowCreationToReturn.signal() + _ = await creationTask.value + + let plan = await store.getSubmitPlan(for: transaction.rawID) + XCTAssertNil(plan) + } + func testTxResubmissionSkipsTransactionsAwaitingSubmitPlan() async throws { let awaitingTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) let legacyTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) @@ -212,18 +411,12 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let transactionEncoder = RecordingTransactionEncoder() let store = PendingSubmitPlanStore(logger: NullLogger()) let submitter = RecordingTransactionSubmitter() - let createCompleted = AsyncFlag() - var createTask: Task? + let lookupStarted = AsyncSignal() + let allowLookupToReturn = AsyncSignal() transactionRepository.findForResubmissionUpToClosure = { _ in - createTask = Task { - await self.markAwaiting([createdTransaction], in: store) - await createCompleted.set() - } - - try await Task.sleep(nanoseconds: 20_000_000) - let didCreateComplete = await createCompleted.get() - XCTAssertFalse(didCreateComplete) + await lookupStarted.signal() + await allowLookupToReturn.wait() return [] } @@ -237,13 +430,14 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { let action = TxResubmissionAction(container: mockContainer) action.latestResolvedTime = 0 - _ = try await action.run(with: resubmissionContext()) { _ in } - - guard let createTask else { - XCTFail("Expected test to start concurrent transaction creation.") - return + let actionTask = Task { + try await action.run(with: resubmissionContext()) { _ in } } - await createTask.value + await lookupStarted.wait() + + await markAwaiting([createdTransaction], in: store) + await allowLookupToReturn.signal() + _ = try await actionTask.value switch await store.getSubmitPlan(for: createdTransaction.rawID) { case .awaitingPlan: @@ -357,6 +551,30 @@ private enum SubmitPlanPersistenceTestError: Error { case loadFailed } +private actor AsyncSignal { + private var isSignaled = false + private var continuations: [CheckedContinuation] = [] + + func signal() { + guard !isSignaled else { return } + + isSignaled = true + let continuations = self.continuations + self.continuations = [] + continuations.forEach { $0.resume() } + } + + func wait() async { + if isSignaled { + return + } + + await withCheckedContinuation { continuation in + continuations.append(continuation) + } + } +} + private final class RecordingTransactionEncoder: TransactionEncoder { private(set) var submittedTransactions: [EncodedTransaction] = [] From fbd26aefb918464bf20d21b3612c9e0e51a723a1 Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 20 May 2026 12:02:34 -0600 Subject: [PATCH 10/16] Document pending submit plan store --- .../Transaction/PendingSubmitPlanStore.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index 85d826bcc..73f9de9f8 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -23,6 +23,12 @@ struct TransactionSubmitPlan { } } +/// Tracks submit plans for pending transactions created through `Broadcaster`. +/// +/// Transactions start as waiting for submit endpoints, then the store records +/// the endpoints used for submission so resubmission can retry through the same +/// path. Plans are persisted across launches and pruned when their transactions +/// are no longer resubmission candidates. actor PendingSubmitPlanStore { enum StoredSubmitPlan { case awaitingPlan From 02ff1de96d43126f9274badabb33c2c77e67076e Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Wed, 20 May 2026 12:08:19 -0600 Subject: [PATCH 11/16] Preserve raw submit endpoints during creation --- .../Transaction/PendingSubmitPlanStore.swift | 36 ++++++++++++++++++- .../PendingSubmitPlanStoreTests.swift | 30 ++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index 73f9de9f8..ab024cc55 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -51,6 +51,7 @@ actor PendingSubmitPlanStore { // In-memory cache only. After restart, raw transaction submissions recover // the transaction id through RawTransactionLookup before recording endpoints. private var transactionIdsByRawTransaction: [String: String] = [:] + private var pendingEndpointsByRawTransaction: [String: [StoredEndpoint]] = [:] private var loadedFromPersistence = false // Actor isolation prevents data races, but actor methods are reentrant // across await. Candidate lookup returns a repository snapshot, so pruning @@ -90,7 +91,11 @@ actor PendingSubmitPlanStore { ) async { loadFromPersistenceIfNeeded() - guard let transactionId = transactionIdsByRawTransaction[rawTransaction.stablePlanKey] else { + let rawTransactionKey = rawTransaction.stablePlanKey + guard let transactionId = transactionIdsByRawTransaction[rawTransactionKey] else { + if activeSubmitPlanCreations > 0 { + addPendingSubmitEndpoint(rawTransactionKey: rawTransactionKey, endpoint: endpoint) + } return } @@ -142,6 +147,7 @@ actor PendingSubmitPlanStore { func clear() async { plansByTransactionId.removeAll() transactionIdsByRawTransaction.removeAll() + pendingEndpointsByRawTransaction.removeAll() clearGeneration &+= 1 bumpStoreRevision() do { @@ -150,7 +156,9 @@ actor PendingSubmitPlanStore { logger.warn("Failed to clear pending submit plans: \(error)") } } +} +private extension PendingSubmitPlanStore { private func markAwaitingSubmitPlan(_ transactions: [ZcashTransaction.Overview]) { var shouldSave = false var didChange = false @@ -162,6 +170,15 @@ actor PendingSubmitPlanStore { transactionIdsByRawTransaction[rawTransactionKey] = transactionId didChange = true } + if let pendingEndpoints = pendingEndpointsByRawTransaction.removeValue(forKey: rawTransactionKey) { + didChange = true + var endpoints = plansByTransactionId[transactionId] ?? [] + for pendingEndpoint in pendingEndpoints where !endpoints.contains(pendingEndpoint) { + endpoints.append(pendingEndpoint) + shouldSave = true + } + plansByTransactionId[transactionId] = endpoints + } } if plansByTransactionId[transactionId] == nil { plansByTransactionId[transactionId] = [] @@ -192,6 +209,19 @@ actor PendingSubmitPlanStore { saveToPersistence() } + private func addPendingSubmitEndpoint( + rawTransactionKey: String, + endpoint: LightWalletEndpoint + ) { + let storedEndpoint = StoredEndpoint(endpoint: endpoint) + var endpoints = pendingEndpointsByRawTransaction[rawTransactionKey] ?? [] + guard !endpoints.contains(storedEndpoint) else { return } + + endpoints.append(storedEndpoint) + pendingEndpointsByRawTransaction[rawTransactionKey] = endpoints + bumpStoreRevision() + } + private func retainPlans(for transactionIds: [Data]) { let retainedTransactionIds = Set(transactionIds.map(\.stablePlanKey)) let previousPlanCount = plansByTransactionId.count @@ -218,6 +248,10 @@ actor PendingSubmitPlanStore { private func endSubmitPlanCreation() { precondition(activeSubmitPlanCreations > 0, "Attempted to end inactive submit-plan creation.") activeSubmitPlanCreations -= 1 + if activeSubmitPlanCreations == 0 && !pendingEndpointsByRawTransaction.isEmpty { + pendingEndpointsByRawTransaction.removeAll() + bumpStoreRevision() + } } private func makeRetainToken() -> RetainToken { diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index d54e43f2f..2270540ec 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -119,6 +119,36 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { } } + func testRawEndpointRegistrationDuringCreationIsPreserved() async throws { + let rawTransaction = Data([0x01, 0x02]) + let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32), raw: rawTransaction) + let endpoint = LightWalletEndpoint(address: "submit.z.cash", port: 443, secure: true) + let store = PendingSubmitPlanStore(logger: NullLogger()) + let creationStarted = AsyncSignal() + let allowCreationToReturn = AsyncSignal() + + let creationTask = Task { + await store.createAndMarkAwaitingSubmitPlan { + await creationStarted.signal() + await allowCreationToReturn.wait() + return [transaction] + } + } + await creationStarted.wait() + + await store.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) + await allowCreationToReturn.signal() + _ = await creationTask.value + + switch await store.getSubmitPlan(for: transaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: endpoint) + default: + XCTFail("Expected raw endpoint registration during creation to be preserved.") + } + } + func testPrunesPlansThatAreNoLongerResubmissionCandidates() async throws { let persistence = InMemorySubmitPlanPersistence() let prunedRawTransaction = Data([0x03, 0x04]) From 6cd9ae20343a16fb0536f2871a1c328a0fe86a0f Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 25 May 2026 11:43:43 -0600 Subject: [PATCH 12/16] fix: propagate raw transaction lookup errors --- .../Synchronizer/SDKBroadcaster.swift | 24 +++++--- Tests/OfflineTests/BroadcasterTests.swift | 55 ++++++++++++++++++- 2 files changed, 67 insertions(+), 12 deletions(-) diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift index 922286f07..6a60e5d32 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKBroadcaster.swift @@ -119,16 +119,22 @@ class SDKBroadcaster: Broadcaster { to endpoint: LightWalletEndpoint ) async throws { // Record before submitting so transient endpoint failures keep the same retry plan. - if let transaction = try? await rawTransactionLookup?.find(rawTransaction: rawTransaction) { - await pendingSubmitPlanStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) - try await transactionSubmitter.submit( - transaction: EncodedTransaction(transactionId: transaction.rawID, raw: rawTransaction), - to: endpoint - ) - } else { - await pendingSubmitPlanStore.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) - try await transactionSubmitter.submit(rawTransaction: rawTransaction, to: endpoint) + if let rawTransactionLookup = rawTransactionLookup { + do { + let transaction = try await rawTransactionLookup.find(rawTransaction: rawTransaction) + await pendingSubmitPlanStore.addSubmitEndpoint(transaction: transaction, endpoint: endpoint) + try await transactionSubmitter.submit( + transaction: EncodedTransaction(transactionId: transaction.rawID, raw: rawTransaction), + to: endpoint + ) + return + } catch ZcashError.transactionRepositoryEntityNotFound { + // Fall back to raw submission for transactions that are not in the repository. + } } + + await pendingSubmitPlanStore.addSubmitEndpoint(rawTransaction: rawTransaction, endpoint: endpoint) + try await transactionSubmitter.submit(rawTransaction: rawTransaction, to: endpoint) } private func createProposedTransactionsWithoutRegisteringSubmitPlan( diff --git a/Tests/OfflineTests/BroadcasterTests.swift b/Tests/OfflineTests/BroadcasterTests.swift index f32438de3..232a2f689 100644 --- a/Tests/OfflineTests/BroadcasterTests.swift +++ b/Tests/OfflineTests/BroadcasterTests.swift @@ -208,6 +208,47 @@ final class BroadcasterTests: ZcashTestCase { } } + func testSubmitFallsBackToRawSubmissionWhenRawTransactionIsNotFound() async throws { + let rawTransaction = Data([0x01, 0x02, 0x03, 0x04]) + let transactionRepository = LookupTransactionRepository(result: .failure(ZcashError.transactionRepositoryEntityNotFound)) + let transactionEncoder = StubTransactionEncoder(createdTransactions: []) + let service = try RecordingCompactTxStreamerService(sendResponse: makeSendResponse(errorCode: 0, errorMessage: "")) + defer { try? service.stop() } + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + transactionRepository: transactionRepository + ) + + try await synchronizer.broadcaster.submit(rawTransaction, to: service.endpoint) + + XCTAssertEqual(transactionRepository.receivedRawTransactions, [rawTransaction]) + XCTAssertEqual(service.recordedTransactions(), [rawTransaction]) + } + + func testSubmitPropagatesRawTransactionLookupErrors() async throws { + let rawTransaction = Data([0x01, 0x02, 0x03, 0x04]) + let transactionRepository = LookupTransactionRepository(result: .failure(BroadcasterTestError.rawLookupFailed)) + let transactionEncoder = StubTransactionEncoder(createdTransactions: []) + let service = try RecordingCompactTxStreamerService(sendResponse: makeSendResponse(errorCode: 0, errorMessage: "")) + defer { try? service.stop() } + let synchronizer = try makeSynchronizer( + transactionEncoder: transactionEncoder, + transactionRepository: transactionRepository + ) + + do { + try await synchronizer.broadcaster.submit(rawTransaction, to: service.endpoint) + XCTFail("Expected raw transaction lookup failure to be propagated.") + } catch BroadcasterTestError.rawLookupFailed { + // Expected. + } catch { + XCTFail("Expected rawLookupFailed but got \(error).") + } + + XCTAssertEqual(transactionRepository.receivedRawTransactions, [rawTransaction]) + XCTAssertEqual(service.recordedTransactions(), []) + } + func testBroadcasterThrowsWhenNotPrepared() async throws { let transactionEncoder = StubTransactionEncoder(createdTransactions: []) let synchronizer = try makeSynchronizer(transactionEncoder: transactionEncoder) @@ -465,6 +506,10 @@ final class BroadcasterTests: ZcashTestCase { // MARK: - Test Doubles +private enum BroadcasterTestError: Error { + case rawLookupFailed +} + private final class StubTransactionEncoder: TransactionEncoder { private let createdTransactions: [ZcashTransaction.Overview] private let beforeReturningCreatedTransactions: () async -> Void @@ -529,11 +574,15 @@ private final class StubTransactionEncoder: TransactionEncoder { } private final class LookupTransactionRepository: TransactionRepository, RawTransactionLookup { - private let transaction: ZcashTransaction.Overview + private let result: Result private(set) var receivedRawTransactions: [Data] = [] init(transaction: ZcashTransaction.Overview) { - self.transaction = transaction + result = .success(transaction) + } + + init(result: Result) { + self.result = result } func closeDBConnection() { } @@ -602,7 +651,7 @@ private final class LookupTransactionRepository: TransactionRepository, RawTrans func find(rawTransaction: Data) async throws -> ZcashTransaction.Overview { receivedRawTransactions.append(rawTransaction) - return transaction + return try result.get() } } From b9ba0c131b299a816fa038934c7520710b5897cd Mon Sep 17 00:00:00 2001 From: Adam Tucker Date: Mon, 25 May 2026 11:46:59 -0600 Subject: [PATCH 13/16] fix: persist submit plans after load recovery --- .../Transaction/PendingSubmitPlanStore.swift | 28 ++++++------- .../PendingSubmitPlanStoreTests.swift | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift index ab024cc55..a45da0dbf 100644 --- a/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift +++ b/Sources/ZcashLightClientKit/Transaction/PendingSubmitPlanStore.swift @@ -279,33 +279,34 @@ private extension PendingSubmitPlanStore { guard !loadedFromPersistence else { return } do { - guard - let data = try persistence?.load(), - !data.isEmpty - else { - loadedFromPersistence = true - return + let shouldSaveAfterLoad: Bool + if let data = try persistence?.load(), !data.isEmpty { + let storedPlans = try JSONDecoder().decode(StoredPlans.self, from: data) + guard storedPlans.version == StoredPlans.currentVersion else { + throw PendingSubmitPlanStoreError.unsupportedVersion(storedPlans.version) + } + shouldSaveAfterLoad = mergeLoadedPlans(storedPlans.plansByTransactionId) + } else { + shouldSaveAfterLoad = !plansByTransactionId.isEmpty } - let storedPlans = try JSONDecoder().decode(StoredPlans.self, from: data) - guard storedPlans.version == StoredPlans.currentVersion else { - throw PendingSubmitPlanStoreError.unsupportedVersion(storedPlans.version) - } - mergeLoadedPlans(storedPlans.plansByTransactionId) loadedFromPersistence = true + if shouldSaveAfterLoad { + saveToPersistence() + } } catch { logger.warn("Failed to load pending submit plans: \(error)") } } - private func mergeLoadedPlans(_ loadedPlans: [String: [StoredEndpoint]]) { + private func mergeLoadedPlans(_ loadedPlans: [String: [StoredEndpoint]]) -> Bool { let previousPlans = plansByTransactionId guard !plansByTransactionId.isEmpty else { plansByTransactionId = loadedPlans if previousPlans != plansByTransactionId { bumpStoreRevision() } - return + return false } var mergedPlans = loadedPlans @@ -322,6 +323,7 @@ private extension PendingSubmitPlanStore { if previousPlans != plansByTransactionId { bumpStoreRevision() } + return loadedPlans != plansByTransactionId } private func saveToPersistence() { diff --git a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift index 2270540ec..357e04d27 100644 --- a/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift +++ b/Tests/OfflineTests/PendingSubmitPlanStoreTests.swift @@ -81,6 +81,45 @@ final class PendingSubmitPlanStoreTests: ZcashTestCase { XCTAssertEqual(persistence.data, originalData) } + func testPersistsMergedInMemoryPlansAfterLoadRecovery() async throws { + let persistence = InMemorySubmitPlanPersistence() + let persistedTransaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) + let recoveredTransaction = makeTransaction(rawID: Data(repeating: 0xCD, count: 32)) + let persistedEndpoint = LightWalletEndpoint(address: "persisted.z.cash", port: 443, secure: true) + let recoveredEndpoint = LightWalletEndpoint(address: "recovered.z.cash", port: 443, secure: true) + let initialStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + + await markAwaiting([persistedTransaction], in: initialStore) + await initialStore.addSubmitEndpoint(transaction: persistedTransaction, endpoint: persistedEndpoint) + let saveCallCountAfterInitialStore = persistence.saveCallCount + + persistence.loadError = SubmitPlanPersistenceTestError.loadFailed + let recoveryStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + await markAwaiting([recoveredTransaction], in: recoveryStore) + await recoveryStore.addSubmitEndpoint(transaction: recoveredTransaction, endpoint: recoveredEndpoint) + XCTAssertEqual(persistence.saveCallCount, saveCallCountAfterInitialStore) + + persistence.loadError = nil + _ = await recoveryStore.getSubmitPlan(for: persistedTransaction.rawID) + + XCTAssertEqual(persistence.saveCallCount, saveCallCountAfterInitialStore + 1) + let reloadedStore = PendingSubmitPlanStore(persistence: persistence, logger: NullLogger()) + switch await reloadedStore.getSubmitPlan(for: persistedTransaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: persistedEndpoint) + default: + XCTFail("Expected persisted submit plan to survive load recovery.") + } + switch await reloadedStore.getSubmitPlan(for: recoveredTransaction.rawID) { + case .ready(let plan): + XCTAssertEqual(plan.endpoints.count, 1) + assertEndpoint(plan.endpoints[0], equals: recoveredEndpoint) + default: + XCTFail("Expected recovered in-memory submit plan to be persisted after load recovery.") + } + } + func testAddsSubmittedEndpointsToExistingPlan() async throws { let transaction = makeTransaction(rawID: Data(repeating: 0xAB, count: 32)) let firstEndpoint = LightWalletEndpoint(address: "a.z.cash", port: 443, secure: true) From 0c26adefecece5401552ccce4e963d192e15002c Mon Sep 17 00:00:00 2001 From: roman Date: Tue, 26 May 2026 12:18:08 -0300 Subject: [PATCH 14/16] [#1744] Leave zcash_voting version locked by Cargo.lock --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 073dca62f..78fd64800 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -84,7 +84,7 @@ rust_decimal = { version = "1", default-features = false, features = ["c-repr"] xz2 = { version = "0.1", features = ["static"] } # Voting -zcash_voting = { version = "=0.10.1", default-features = false, features = [ +zcash_voting = { version = "0.10.1", default-features = false, features = [ "client-pir", "client-tree-sync", ] } From 47120cde6ebbc3c2490e8c7186642e5e1b55997c Mon Sep 17 00:00:00 2001 From: Lukas Korba Date: Wed, 27 May 2026 17:40:29 +0200 Subject: [PATCH 15/16] Checkpoints updated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mainnet 3340000 → 3355000 (interval 2500). Testnet 4010000 → 4030000 (interval 10000). Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 18 ++++++++++++++++++ .../Resources/checkpoints/mainnet/3340000.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3342500.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3345000.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3347500.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3350000.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3352500.json | 8 ++++++++ .../Resources/checkpoints/mainnet/3355000.json | 8 ++++++++ .../Resources/checkpoints/testnet/4010000.json | 8 ++++++++ .../Resources/checkpoints/testnet/4020000.json | 8 ++++++++ .../Resources/checkpoints/testnet/4030000.json | 8 ++++++++ 11 files changed, 98 insertions(+) create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3340000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3342500.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3345000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3347500.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3350000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3352500.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3355000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4010000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4020000.json create mode 100644 Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4030000.json diff --git a/CHANGELOG.md b/CHANGELOG.md index b81125bd1..2d60bf89f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 # Unreleased +## Checkpoints + +Mainnet + +```` +Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3340000.json +... +Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3355000.json +```` + +Testnet + +```` +Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4010000.json +... +Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4030000.json +```` + # 2.5.1 - 2026-05-14 ## Fixed diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3340000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3340000.json new file mode 100644 index 000000000..8a794a8ce --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3340000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3340000", + "hash": "0000000000176c646f484fd89f0849d5d12b2e72fc3b6bb19f28513c131542aa", + "time": 1778604451, + "saplingTree": "017d5d63df6a14192d72dc5e6e2a6a71fe973ca6fcf9a5857593a3fea29f7b9b24001f000001ea8124cfa09f07e8fb7fc4cb0a3b057b4b4632d76e5b5a2d6a036019f290fa5701f9c1a177d696088c1398689bc6f444d8613325e0118158c83c71ac3e1832376101381defcce60fe6d2eda20d3de8c1ccf5b3e1ae4dab9c0a6782faa15fc056960c0164a0c7286c5a9196246d0a0db160c876377cea24154133bcda23acc06ca1fa32015ed6a4099c41ded03f5dbec882a691849d4b62e0c2dff282c7a0250cefbb024c01319578858ea9e3381642e979dd307c59c2aa360392ec75e5a88c23143457f76c01b6dd3f9fe1163acb4750b9dedde2b9be4741b1ebe94cc3a676dac57ec230e461000120cc0c0ff8e16bd54c723a4340007c266b33f0a99ef18aa0a4a2cf3889f0375b01b79e8339dd14c75d334b7d22837c83b54af56dc8ffc00c1d9e000d73218c1f4501e1ed2cd5a21ca655ae19e2f2c367d0039f37ee35ba856791768e01e8b5012d5b00011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "012ff8ec2da5684e75195ca3be148c2d1fdab647848993c57a0094ae56b3f8aa1a001f00016be1e58474aa3149c3134be705a323141bba08d15fda001449ee24d1473e55380175414578d3f0c6104759ba284786e7f647214afeced76161f22dca0fb0671908015bc02f397a949f4195c09d547f22b1c5243d43cd6d7187f959f1aee7ff69de0c0197ef4dc418b370caff537b1557da0a466ff9440c0109f1f248f56cbec7c78b16010eb8701f4fb780766f8dc8050fc47a1529ec1f6ea4ac2fe4191fdac7ff91041c00013a71bdcbf164f8fa61cf6a3a057167fb91fcc9296df6222d5d010c248dc7f13501bc080d714f1761a7526c31badf0a5cc7d9b192f7f9380196fcc3ce2146f71b13000188c7f21ce6fbdc7d161545b0050aa75aeeb0884849326b33180ca0365cb6210b00000140234bc97e83a54c01338f668afbf26a7e88d945d266bf2374ed25b051e8e92e01d2cadcf4fa18a265421bb67f40cdf518a208d55ea1cbaf7bdaaa12df99860d000001e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3342500.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3342500.json new file mode 100644 index 000000000..083cae4d5 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3342500.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3342500", + "hash": "0000000000eb9738c9af194d316f17ab50281aa89e5afcdb40f226a507a0d04f", + "time": 1778792821, + "saplingTree": "01e76a2bf8512779f9cf1885eec1fa2b7cb7f34ae687dd2a73013e0334f2aed15e011dcd24172dc97f8dadc0923c4c839a863a94e46f3815ae3694fd727c851c8f5c1f000001ee90f27235279cc5ca519aca4a7f78c43de15561c769714773b0b5aee455534e0001f17d34eaf3ca8b583b8bbc946c85cc8af0a5b6175197bbdb986cc001cc606220011e4d0591346a7b100a283de68a5e0525f05c4aa931829a7e02192ed4d5cf2925017e5b5e5d645d836e58bce4247e154448c1a0b78bbf732cb6c3943cd7221bfe59016ef74376850fcc46d8892d318c61840ef4c58e84d1b86b3ee3188d23507f555c011709749511f2953f4593c45ef9cd3b291cf32a59b5c288e8618048b84d7fc157013f3c85d10f47339825160e6ff786ca15c7fd0c59b7882262f44a57c266b618140120cc0c0ff8e16bd54c723a4340007c266b33f0a99ef18aa0a4a2cf3889f0375b01b79e8339dd14c75d334b7d22837c83b54af56dc8ffc00c1d9e000d73218c1f4501e1ed2cd5a21ca655ae19e2f2c367d0039f37ee35ba856791768e01e8b5012d5b00011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "017f31b6a61fda71f16acf5e166943a521cbf315d43da5968fb6bb43ab22b84610001f014979e7186e69dbe9245f3b7f9b78bae4c1fe415ac00f878521ac8368a0b1042801e377f0b90d2a206e50cffe05c013a5c5e9c3aed8ce814c6c91f9d45535f7311c018d3742b81f24cc442ceab99559f8d2b5763cab43855edec409c248eae5c3b831000191aa3773139ffeac41c047e95d62d0d82a59c958856c2b0435de03630e0d6730000000019d691d2298d03f64d50066b2d3ab467c68b0d5a0cad13800023c68e003f34b2d01457e5c3b9a7aadbeb5e588d7293e3e975f2e7618a06af0071ea93847a4aa0806000001e7618eaf8e4ff94a93e524fa891e5b9b89cb782beb1de3c85a0bfe6277ae561b0140234bc97e83a54c01338f668afbf26a7e88d945d266bf2374ed25b051e8e92e01d2cadcf4fa18a265421bb67f40cdf518a208d55ea1cbaf7bdaaa12df99860d000001e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3345000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3345000.json new file mode 100644 index 000000000..b9da850e6 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3345000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3345000", + "hash": "0000000000b7b5080387458121c500fc8acdf206d0593dd4b1edf521360e129d", + "time": 1778981173, + "saplingTree": "0135066bb27d3fca80a062fc5a93f16190d685a73df86906bb631ab648d5bb7c64001f00010095e8adc3562c9253e0a5de1078d0e77cbf73a53e5a532da1eb3dd0c4cd6e0c0001e738edf6ee6b5962d36fbfaca849f45c04a11505b7ad8bf4319b0d9e99f8f757015a6cdac798990a9ea89d75339d4b2680ed6b9451b522be32751e896c29a58835000001b6d99a6c10600e476369a4fcccb49ceb006d0793729f2e873afffaf4fe483b4a012583d2be1bbf223256da79ed6d8236a09f98be2f00510ffd0f0642396a5dbd56000000000190e323f6e45d107d4c5cbe2c4cb4c18a466d71c43fb4b4613d3617ed45d8f456011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "018467f8278e5c71b0208ec78180ce2219b38752ecc78bd1d66a88b1a30dda2f3b001f0000014273294140237ee74298e3021c9cdeaa01711b55d7b37062cf7171845d26a503000001307dd02629646d1cc16e4924b7012c493274829aac3fa7e62dff9cf832a6653a0134097cbe1facd71344ab30e47d546a60313a24c670df5897b3971ac82656271901533027309e1db0ed54f26beed346d574806e6abb0d73422fdbeb87137401011f0001fa34935d099df0fc5f49105505f1b5667a5c4216cb2c13de0f96165955827a0a01df40423ed5c4216a0a75cb87d4d48c0e2e39363cfa225ea0c0c433697b520b05018b51e53f3e69044df9343e294a8b70c16a16ffbd3c5529e55d12e6ef5f2dff0401e7618eaf8e4ff94a93e524fa891e5b9b89cb782beb1de3c85a0bfe6277ae561b0140234bc97e83a54c01338f668afbf26a7e88d945d266bf2374ed25b051e8e92e01d2cadcf4fa18a265421bb67f40cdf518a208d55ea1cbaf7bdaaa12df99860d000001e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3347500.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3347500.json new file mode 100644 index 000000000..f00d45200 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3347500.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3347500", + "hash": "0000000000879a8d5268093eab17f8008ffd0fe9fb59087275109cd46e8011aa", + "time": 1779169130, + "saplingTree": "016f071150972d222609f043559ad9ac8521e1a1ff9d1adcc7dc3fad70cc11950d001f01f22527c3e131804418d15c9f9048de2fd57da368ff4976b3855c5eca0e96701000000001d713d6c4f874f0c383f0b1ad62b701e42273dba52c4d1c6792b66a7de4d2d13200011962e10bbb9dcee3ab1220162ba1a4375c05f2958694c586ea32a45e0c391b2d013e1a2e624b23293be89330a79b619eaaa6b77be3f8a0c421e8993b41bcde0f1601c1aa51d39b9b9abc5c2c748569b2c26a13e8f7c3dc0469402a857dd8a8e6363501c98d353959b3ac414a82f2c714e1c589d84ae2a277db946761422188046cff040000000190e323f6e45d107d4c5cbe2c4cb4c18a466d71c43fb4b4613d3617ed45d8f456011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "01f39ab659655937b4ab428b461a668460678bd52c64de4aec9556c84d3163ff19001f00000175b050fff4d627227048fdaecf0161bd324d42e49e61fea5ac4a989f335dc10e0000011e5506abb4e51ec113021ac6e9d8fa3eaad6ea67786045e153806d386f2f5a270001bb75504716851fff3039de74da02e73a161d41e2329fcc2518b3c926537f432e0001b18fbc455cba5fc45e3459795eaee992eaac3a73b66cc00b48c8761432e9a12000015525a5ccc765e2f933777090dd6b248b79d43f966e384828e7817ac84a14ea1e000000012058ed30845b642624f5e97c395da919472fc4cf33310e9c6c5592fb2402863a01e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3350000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3350000.json new file mode 100644 index 000000000..6fe66bd82 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3350000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3350000", + "hash": "00000000007cf26cfa1ad66d6b09d49adb6b4cf18bf6409bfc621600c01504e1", + "time": 1779359129, + "saplingTree": "01fca7be66740db6352ed4499a6f4bb4508a6e46bc33f2d636cb19b362b21ba739001f0000000159e0fbf2bc549165950198a8fb11866defa168404dcb3b4f31d3e4d5a57e941a0000000157aff948d08dc586d69663d49816ebf59dc466cf4ba819903e865f39c41cf75500017d1a68c8d2a5fa9c517c8b24dd71ccbb332a7bf6e2518d71a253f88c4905584f012946f1f71e50fe21515671354c0447f30378066e0ed9c12664bae82f3cc77b1300000190e323f6e45d107d4c5cbe2c4cb4c18a466d71c43fb4b4613d3617ed45d8f456011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "01984c9b721a04ec43f83af2fe55be4d5c35a3ffe4c5ef66f5c470f36656c30f01010355c689bb56ce852be016174c5dfb9dc318afeb7d073f65017ac66babac5e321f01307c9f8ee0f436765b99d2314a9d1bf9005bf7d43a57b29593efc2ee1d4e42120000000101a6c04f0de2a7683af45e5e89a71498c3f916da5857454f85835ae0765e5b1c01024543199efdffc0bf65e01742445be10f0e29a803117320f1465846d6a73a38000114e9dad761c617620e2222f7d0f13c21330fbdcb2fb6ad467b673cf5273c4b1b000001ddaf39259ef5e6d5b21d4213c1452be45e2f7af711bbb1176c3cf973a8c45e250164a1f5c861aea42e28991321d00dbb8ed5a0bf0a9e51a453b5766b10fabc5f2a01d4607d9c3a44b2a0e4a25a4852a897c1da40bd8e168183ba8177407a452d9d180000012058ed30845b642624f5e97c395da919472fc4cf33310e9c6c5592fb2402863a01e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3352500.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3352500.json new file mode 100644 index 000000000..799979e6d --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3352500.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3352500", + "hash": "0000000000cf202c8643fbf7ca093ee0f07009a825a44373b0a93cce19ca63f3", + "time": 1779546689, + "saplingTree": "013001b00dd62d22043faa18aac0e1d910efa72f85effb11ea62c48f4e3912ee6a01404c7f8bef36834ac7298903c08aabc4ccd89db46f2909a350e865f3f05e3b5d1f000113601c1c81220221ef951358876cd4d9da5906a9571a302798f96e9e7c554c0301ff94607283df1d14b4a82045b7fda47406e31e23cdc464cfee9b611454b58a43000149bc260f641eab6ab718429ca334409017f3acaf8ca7977b2c506429a4e31a5e00019dc1b006cbf213f501168438ed1dea89e50ba3c79850728c98f3a0006fad5969000000000108e503d54848e8fa94b9e24529ba433446f05f9cb5ca52cf046465b24dad943e000190e323f6e45d107d4c5cbe2c4cb4c18a466d71c43fb4b4613d3617ed45d8f456011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "0143ff87c0b887fd4257932d52311438a4bf0245cd665d38134ccef840afa7ea1101f2c440803dd8c7338c225473970bfad8b42b8112b3d78bdab0e632bdfd4883181f01ed06ffeda40e05f5a7d86ff65e6a3668cdc4179df7819e27b24d6baec24ac93f000001bd0ca0410d312056ca59f7193a88bb39181d07b9e887f64be7d2d742ed06383c01d992f887741a658b26bee99473fe244aec45c5b13d99a44316e114b5aa5bfb320001e69c5be956f97f2d8c54b291048f736692e79816979a1dfa4b4b0307841cba2600017d2d178186b1712f989cbdbfd1f6b7548baa3faa4821e415b4f5b9aec569ab350001affd3b90f0107efa191b7534975d7d90297857dc64df84761f068277ceab2f2501e353f33c91dd78c08a7b780f91ea19c11638742279254e8ebe7c37b63fce5e0c00014f3429646cead4e536f08fc228dc2730bbe321ab69896dfcf11b5536c94b1a0200012058ed30845b642624f5e97c395da919472fc4cf33310e9c6c5592fb2402863a01e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3355000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3355000.json new file mode 100644 index 000000000..649ed3d13 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/mainnet/3355000.json @@ -0,0 +1,8 @@ +{ + "network": "main", + "height": "3355000", + "hash": "0000000000f8017608ad386df168730565beef469bc9fe2ad402752cf45f89c1", + "time": 1779735701, + "saplingTree": "01c4e24f9091b35870a80de36fa8daaee35c1f112a573e72180d3059bf207eae59011a31008743b022ec693ed84ede12345b1f0a22eeff248e31bd244dd85b1955311f01eb55994a0711f3d23e9280b2266f3c6097182509940b8ecbbdb64037877da31701d30ffafe1629ed5dd17f09a53d737864c01e9c45c524face17fdb1d6527dff680000000000019ba2584583c1fa740d356077a3e217cbd606ef7e1fa0e46f8aa9769fa5e99a68000164c8da2bf44c7fa2c35f57072c9c812933a8c6b63f15793a5786823e5c84182d000108e503d54848e8fa94b9e24529ba433446f05f9cb5ca52cf046465b24dad943e000190e323f6e45d107d4c5cbe2c4cb4c18a466d71c43fb4b4613d3617ed45d8f456011d85b015e936981d3a869c16ed2e03aeeb2444206933acc3f7b384aeb963256601929f0d44045c58428bd8daca3e2c9ca5a0361b5573a4158251dbe9f63a84e94901931c48ea50688eae5e54c24d0df7a6b00ce498f92eb5e1cfd6a2d87d8ebd5364017d1ce2f0839bdbf1bad7ae37f845e7fe2116e0c1197536bfbad549f3876c3c590000013e2598f743726006b8de42476ed56a55a75629a7b82e430c4e7c101a69e9b02a011619f99023a69bb647eab2d2aa1a73c3673c74bb033c3c4930eacda19e6fd93b0000000160272b134ca494b602137d89e528c751c06d3ef4a87a45f33af343c15060cc1e0000000000", + "orchardTree": "010a4f99841792abd598deff4b27a6c8d5eb18a27bcfb7cb853aca11984d91630001ece0b9b8e962024ef5dd81e60e7366c747ba02571a845ae1809130fbde1878091f018aa47422bd86a349a9467f48fe775c71c6b5bee41a0df95c591417bd52adae3c000111c3cfe03ff54bcbbdb78e4294986c526dfd41a1dcf4a266f70784342e7a2e0b00017c11877b85f62dfccd4581de5fde5689b3624a2e2da17a7bb67a865900f7761300000001ebe39d579d72eaa76d8049d7349079a0f53e2dc1888cba5db9a8388c868029250001e5b9c3f03f51811ad9b025eb943a3c5e3a148ca6ef0202b353d6a18e26e1b90201593ee13b5b18a4e9b634cd34d5fe54e0b63554e91e5bae3dfb190fac22aae91001360fc68d39310b4be57b7ea68d816de245d6396172e54eb2b3aa257b2daa2627014f3429646cead4e536f08fc228dc2730bbe321ab69896dfcf11b5536c94b1a0200012058ed30845b642624f5e97c395da919472fc4cf33310e9c6c5592fb2402863a01e4bb5b2b7df9f4c3f508da258d91255399df66e84af8795377b7696bb87df42300012829e8aacdf1501baaeb5cb6e189d4e7182228e3d4b9acf54713595241e97f21017c8ece2b2ab2355d809b58809b21c7a5e95cfc693cd689387f7533ec8749261e01cc2dcaa338b312112db04b435a706d63244dd435238f0aa1e9e1598d35470810012dcc4273c8a0ed2337ecf7879380a07e7d427c7f9d82e538002bd1442978402c01daf63debf5b40df902dae98dadc029f281474d190cddecef1b10653248a234150001e2bca6a8d987d668defba89dc082196a922634ed88e065c669e526bb8815ee1b000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4010000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4010000.json new file mode 100644 index 000000000..3dd5cb69a --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4010000.json @@ -0,0 +1,8 @@ +{ + "network": "test", + "height": "4010000", + "hash": "007b5b24b9f36660d90ccbbdeaef6478ff8727bc7908c7626a540a7bfbd24336", + "time": 1778712458, + "saplingTree": "0117aa2f6db44b8e9a62b5de33409a22c49f7eef486f0f4a17ac2ca2d4d892cd50001f0001e13779c5bc9c5628038e58d2631fe3ffffc5a42f58af0c781d96febba1f0504701f5befc0a0dd09f2acc23a67c4b5bdd11cfc40c67514f179854b824a0e07245730000019cb955ac65240f55c6207421778cece300fa945158dc4ad5ad77ed01f409ed390001205b00892ac99a7b822ae9c61b0410e4c6f2eda9810d00f1a282856ff842461701283784edf06bab946430e3e9f997fc1651db475a50037914b39ba8e055b4fd5000000000000192daea87f9b69435a62ab99f433c0a4146574f28158e729c2cf0ceeab185162c01dd49c2114699f3cdcbf7b573cf9fb0782b4d893bf068cccf83d7b69a63ad271600016c8ccd12a93c240aa208bfcd527270aab8afd0b7e9f160114a10335b8167d34900000000000000000000000000", + "orchardTree": "01491063fc488ede5354d123d6dbebeb13335f8940f5b6851e1ce959a3edf2201b001f01dd1be1f6227f1f343b81740b898dff2b9dfa8130178585f403f5f651b421a30e000001b2535641165fc4702c952951e6e623508f0b4db9bc99d687e5b7b7dd146a4207010855dbc35a792cd5bc550acde46428bc45e405a954a9e47a1ed26c8acd83802701e8acc0994eead4f4a9847ff0bd480585e3826b9d587f1fec6785bbddba59650e014a1ae69355ea5f7c50f4916486613477dec1c353a07e99dfe8e998afa64cad3b0151eb37f518dfc2d33e59a1125dbf0b36ca5f07066a9f77f516b9556459e5483f016f3a4019a1517dcf5ea8f592445e6c23e88facf2174ee756b6fdf6681bba8337015c9712644cc11238ec934bcce3d4a6d528f11568cd90003eac4fe7ee971b4b28011a429a43165a83aa78f94e123a4afc623cec20f0ef774fc9edc754955b166a220001b27c63b3acf29f959d78f44522e91b9820140e382ccead5888edcfa562bc702f0001d7bf49bd778859971a7297f24129a1a12c5b3da28b43a766c5ad9f6accc30f07000165702b4337a80dbc279d6b6d2fb16d177975f9f5de0a05851769e91c0815d9210000000000000000000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4020000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4020000.json new file mode 100644 index 000000000..14f234de0 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4020000.json @@ -0,0 +1,8 @@ +{ + "network": "test", + "height": "4020000", + "hash": "00d4b38ecea4744d26d86e4fc50e62d0f79b81eb372b3c64abe5476e66d2503e", + "time": 1779209441, + "saplingTree": "0124d66bedf7ddccfc77b9bcde4564057128b64839507803169aada67612ef675b0168df398e4bfdbf69e0368db1ab85c6042720c1a68cb8d1e8e43de6dd05a835341f01c3d785e6dbca7982737c95766e444e9fdad4c047e390a5f724266fca37bc3b6001e229af1ef37846dc7f016c0870c0855fc09cd9ece0eec655b5562e3ed4917450000106a518dfc7c121c9a388fcfe39db599230a706c9aa21a90ae5d73cc8ad717f2600019cb955ac65240f55c6207421778cece300fa945158dc4ad5ad77ed01f409ed390001205b00892ac99a7b822ae9c61b0410e4c6f2eda9810d00f1a282856ff842461701283784edf06bab946430e3e9f997fc1651db475a50037914b39ba8e055b4fd5000000000000192daea87f9b69435a62ab99f433c0a4146574f28158e729c2cf0ceeab185162c01dd49c2114699f3cdcbf7b573cf9fb0782b4d893bf068cccf83d7b69a63ad271600016c8ccd12a93c240aa208bfcd527270aab8afd0b7e9f160114a10335b8167d34900000000000000000000000000", + "orchardTree": "01b8591485cad94b4c51303c3e0f478c658763633ea133ec92224278431aa1c925001f0138966bef40db0c3755635eba8aa6fb89dac67d0cc51c6d9036dc13f8d78c6518018fbcfdb5ca91098c120bb9cbee7120909880a010decb8380a89b0368f30e2807016350cc4bbdf5e83796c09aa6dd6b8d773ae246ee616efe57b1cdc737d856a9310001ca31a295cf0a7fb97fbdb7223544e4406a8f758a401b10e925b57b0b018c8915000001357fe8d53c9668d7a0d3c31b54b3a115dcbea81c19acd946f5062e922048373a000000000001c63a66bce49e365e0a705c99d61b12e30dd66d2216eab6198bc63a875498911a01d7bf49bd778859971a7297f24129a1a12c5b3da28b43a766c5ad9f6accc30f07000165702b4337a80dbc279d6b6d2fb16d177975f9f5de0a05851769e91c0815d9210000000000000000000000000000" +} diff --git a/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4030000.json b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4030000.json new file mode 100644 index 000000000..b3022b495 --- /dev/null +++ b/Sources/ZcashLightClientKit/Resources/checkpoints/testnet/4030000.json @@ -0,0 +1,8 @@ +{ + "network": "test", + "height": "4030000", + "hash": "002716f8f5892707d77de3795441ebd99b9742a8892151b937b1e5706f3d5980", + "time": 1779715633, + "saplingTree": "01045dad2e74b448fffb975bfdba55f6451f3cb817439e0bbb610d939246c1bb32001f0000012cff9f5db15df25fa0d5c9bb8413cafd923e4dafca962c69860e5d856f2f2f0d0106a518dfc7c121c9a388fcfe39db599230a706c9aa21a90ae5d73cc8ad717f2600019cb955ac65240f55c6207421778cece300fa945158dc4ad5ad77ed01f409ed390001205b00892ac99a7b822ae9c61b0410e4c6f2eda9810d00f1a282856ff842461701283784edf06bab946430e3e9f997fc1651db475a50037914b39ba8e055b4fd5000000000000192daea87f9b69435a62ab99f433c0a4146574f28158e729c2cf0ceeab185162c01dd49c2114699f3cdcbf7b573cf9fb0782b4d893bf068cccf83d7b69a63ad271600016c8ccd12a93c240aa208bfcd527270aab8afd0b7e9f160114a10335b8167d34900000000000000000000000000", + "orchardTree": "01ae065bf9cec2e44867f1ad466b2a7e708ceebd235e77a617572e2eb9cdc46e02013735e0c49562947b3685e56eb5220fcfd99dbf1a5edefee2976bdd158b58c03e1f00000161449b0c4546b8fb53836cb3cb08ec2acd3e9a79d8f95647529490128da3ef2e00000185bb11902e1ccff8a80d84cc15f9fd2b083505127f9b9a16bbc79f9175f23c07000001e4738c1d9f77e06585e3e467ec4b43479f4fafc5a1f1e243c808e63ae0066b240118d76702c49872eee0d16ec0cbca0c93e8cb535608b74378ef75746150f0d71e00000001c63a66bce49e365e0a705c99d61b12e30dd66d2216eab6198bc63a875498911a01d7bf49bd778859971a7297f24129a1a12c5b3da28b43a766c5ad9f6accc30f07000165702b4337a80dbc279d6b6d2fb16d177975f9f5de0a05851769e91c0815d9210000000000000000000000000000" +} From b2fbf559b1aee6fcf68748e947209bb27d7c4c64 Mon Sep 17 00:00:00 2001 From: Kris Nuttycombe Date: Wed, 27 May 2026 13:24:25 -0600 Subject: [PATCH 16/16] Release zcash-swift-wallet-sdk version 2.6.0-alpha.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- Package.swift | 4 ++-- rust/CHANGELOG.md | 11 ++++------- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d2a06aaa..4ebadd497 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2508,7 +2508,7 @@ dependencies = [ [[package]] name = "libzcashlc" -version = "2.5.1" +version = "2.6.0-alpha.3" dependencies = [ "anyhow", "bindgen", diff --git a/Cargo.toml b/Cargo.toml index 78fd64800..d5807b71c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libzcashlc" -version = "2.5.1" +version = "2.6.0-alpha.3" authors = [ "Jack Grigg ", "Lukas Korba ", diff --git a/Package.swift b/Package.swift index 4c2fc1741..6b566ecc1 100644 --- a/Package.swift +++ b/Package.swift @@ -30,8 +30,8 @@ if useLocalFFI { targets.append( .binaryTarget( name: "libzcashlc", - url: "https://github.com/zcash/zcash-swift-wallet-sdk/releases/download/2.6.0-alpha.2/libzcashlc.xcframework.zip", - checksum: "eab2e1be4b884b35af2333cd597a9570cff2a128dfb9d67a771451fefcd952e3" + url: "https://github.com/zcash/zcash-swift-wallet-sdk/releases/download/2.6.0-alpha.3/libzcashlc.xcframework.zip", + checksum: "a7ab6e4174cb834b17f7e54d6d150d84afc757ee40299ab48ea5e1cba2ef8c58" ) ) sdkDependencies.append("libzcashlc") diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md index 9bf0ccca9..fd836dd5a 100644 --- a/rust/CHANGELOG.md +++ b/rust/CHANGELOG.md @@ -6,13 +6,10 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased -### Changed -- Pinned `zcash_voting` to `=0.10.1` for the pre-launch voting SQLite schema - reset, crate-owned recovery store missing-row errors, required voting input - validation, paginated vote commitment tree sync responses with per-block - roots, and the shared Orchard note conversion used by - `zcashlc_voting_get_wallet_notes`. Dropped the unused round ID from - `zcashlc_voting_generate_hotkey`. +## 2.6.0-alpha.3 - 2026-05-27 + +### Added +- Added dependency `zcash_voting 0.10` & supporting FFI methods. ## 2.5.0 - 2026-05-11