From d8affaebd3338f0509fee8de7620b1f03a177af2 Mon Sep 17 00:00:00 2001 From: Francisco Gindre Date: Tue, 3 Dec 2019 12:19:44 -0300 Subject: [PATCH] Transaction submission + tests (#34) * transaction manager * Transaction Manager Tests * pending transactions DAO + Scaffold tests * PendingTransactionsDao + tests * Persistent Transaction Manager --- .../Block/Storage/Storage.swift | 2 + .../DAO/PendingTransactionDao.swift | 158 ++++++++++++- .../DAO/TransactionBuilder.swift | 4 +- ZcashLightClientKit/DAO/TransactionDao.swift | 12 +- .../Entity/PendingTransactionEntity.swift | 6 +- .../Entity/TransactionEntity.swift | 2 +- .../PendingTransactionRepository.swift | 4 +- .../Repository/TransactionRepository.swift | 2 +- .../Service/LightWalletGRPCService.swift | 19 ++ .../Service/LightWalletService.swift | 33 ++- .../PersistentTransactionManager.swift | 168 ++++++++++++++ .../Transaction/TransactionEncoder.swift | 8 +- .../Transaction/TransactionManager.swift | 34 +++ .../WalletTransactionEncoder.swift | 13 +- .../OutboundTransactionManagerTests.swift | 219 ++++++++++++++++++ .../PendingTransactionRepositoryTests.swift | 174 ++++++++++++++ .../WalletTransactionEncoderTests.swift | 4 +- .../ZcashRustBackendTests.swift | 17 +- .../utils/FakeService.swift | 16 ++ ZcashLightClientKitTests/utils/Stubs.swift | 13 ++ .../utils/TestDbBuilder.swift | 3 + 21 files changed, 873 insertions(+), 38 deletions(-) create mode 100644 ZcashLightClientKit/Transaction/PersistentTransactionManager.swift create mode 100644 ZcashLightClientKit/Transaction/TransactionManager.swift create mode 100644 ZcashLightClientKitTests/OutboundTransactionManagerTests.swift create mode 100644 ZcashLightClientKitTests/PendingTransactionRepositoryTests.swift diff --git a/ZcashLightClientKit/Block/Storage/Storage.swift b/ZcashLightClientKit/Block/Storage/Storage.swift index f37518d1..da86c0cf 100644 --- a/ZcashLightClientKit/Block/Storage/Storage.swift +++ b/ZcashLightClientKit/Block/Storage/Storage.swift @@ -22,4 +22,6 @@ enum StorageError: Error { case openFailed case closeFailed case operationFailed + case updateFailed + case malformedEntity(fields: [String]?) } diff --git a/ZcashLightClientKit/DAO/PendingTransactionDao.swift b/ZcashLightClientKit/DAO/PendingTransactionDao.swift index 1c70d963..bbae78ec 100644 --- a/ZcashLightClientKit/DAO/PendingTransactionDao.swift +++ b/ZcashLightClientKit/DAO/PendingTransactionDao.swift @@ -6,37 +6,179 @@ // import Foundation +import SQLite +struct PendingTransaction: PendingTransactionEntity, Decodable, Encodable { + + enum CodingKeys: String, CodingKey { + case toAddress = "to_address" + case accountIndex = "account_index" + case minedHeight = "mined_height" + case expiryHeight = "expiry_height" + case cancelled + case encodeAttempts = "encode_attempts" + case submitAttempts = "submit_attempts" + case errorMessage = "error_message" + case errorCode = "error_code" + case createTime = "create_time" + case raw + case id + case value + case memo + case rawTransactionId = "txid" + } + + var toAddress: String + var accountIndex: Int + var minedHeight: BlockHeight + var expiryHeight: BlockHeight + var cancelled: Int + var encodeAttempts: Int + var submitAttempts: Int + var errorMessage: String? + var errorCode: Int? + var createTime: TimeInterval + var raw: Data? + var id: Int? + var value: Int + var memo: Data? + var rawTransactionId: Data? + + func isSameTransactionId(other: T) -> Bool where T : RawIdentifiable { + self.rawTransactionId == other.rawTransactionId + } + + static func from(entity: PendingTransactionEntity) -> PendingTransaction { + PendingTransaction(toAddress: entity.toAddress, + accountIndex: entity.accountIndex, + minedHeight: entity.minedHeight, + expiryHeight: entity.expiryHeight, + cancelled: entity.cancelled, + encodeAttempts: entity.encodeAttempts, + submitAttempts: entity.submitAttempts, + errorMessage: entity.errorMessage, + errorCode: entity.errorCode, + createTime: entity.createTime, + raw: entity.raw, + id: entity.id, + value: entity.value, + memo: entity.memo, + rawTransactionId: entity.raw) + } +} + +extension PendingTransaction { + + // TODO: Handle Memo + init(value: Int, toAddress: String, memo: String?, account index: Int) { + self = PendingTransaction(toAddress: toAddress, accountIndex: index, minedHeight: -1, expiryHeight: -1, cancelled: 0, encodeAttempts: 0, submitAttempts: 0, errorMessage: nil, errorCode: nil, createTime: Date().timeIntervalSince1970, raw: nil, id: nil, value: Int(value), memo: nil, rawTransactionId: nil) + } +} + class PendingTransactionSQLDAO: PendingTransactionRepository { + + let table = Table("pending_transactions") + + struct TableColumns { + static var toAddress = Expression("to_address") + static var accountIndex = Expression("account_index") + static var minedHeight = Expression("mined_height") + static var expiryHeight = Expression("expiry_height") + static var cancelled = Expression("cancelled") + static var encodeAttempts = Expression("encode_attempts") + static var errorMessage = Expression("error_message") + static var submitAttempts = Expression("submit_attempts") + static var errorCode = Expression("error_code") + static var createTime = Expression("create_time") + static var raw = Expression("raw") + static var id = Expression("id") + static var value = Expression("value") + static var memo = Expression("memo") + static var rawTransactionId = Expression("txid") + } var dbProvider: ConnectionProvider - + init(dbProvider: ConnectionProvider) { self.dbProvider = dbProvider } - func create(_ transaction: PendingTransactionEntity) throws -> Int64 { - -1 + func createrTableIfNeeded() throws { + let statement = table.create(ifNotExists: true) { t in + t.column(TableColumns.id, primaryKey: .autoincrement) + t.column(TableColumns.toAddress) + t.column(TableColumns.accountIndex) + t.column(TableColumns.minedHeight) + t.column(TableColumns.expiryHeight) + t.column(TableColumns.cancelled) + t.column(TableColumns.encodeAttempts, defaultValue: 0) + t.column(TableColumns.errorMessage) + t.column(TableColumns.errorCode) + t.column(TableColumns.submitAttempts, defaultValue: 0) + t.column(TableColumns.createTime) + t.column(TableColumns.rawTransactionId) + t.column(TableColumns.value) + t.column(TableColumns.raw) + t.column(TableColumns.memo) + } + + try dbProvider.connection().run(statement) + } + + func create(_ transaction: PendingTransactionEntity) throws -> Int { + + let tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction) + + return try Int(dbProvider.connection().run(table.insert(tx))) } func update(_ transaction: PendingTransactionEntity) throws { - + let tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction) + guard let id = tx.id else { + throw StorageError.malformedEntity(fields: ["id"]) + } + try dbProvider.connection().run(table.filter(TableColumns.id == id).update(tx)) } func delete(_ transaction: PendingTransactionEntity) throws { - + guard let id = transaction.id else { + throw StorageError.malformedEntity(fields: ["id"]) + } + do { + try dbProvider.connection().run(table.filter(TableColumns.id == id).delete()) + } catch { + throw StorageError.updateFailed + } + } func cancel(_ transaction: PendingTransactionEntity) throws { + var tx = transaction as? PendingTransaction ?? PendingTransaction.from(entity: transaction) + tx.cancelled = 1 + guard let id = tx.id else { + throw StorageError.malformedEntity(fields: ["id"]) + } + try dbProvider.connection().run(table.filter(TableColumns.id == id).update(tx)) } - func find(by id: Int64) throws -> PendingTransactionEntity? { - nil + func find(by id: Int) throws -> PendingTransactionEntity? { + guard let row = try dbProvider.connection().pluck(table.filter(TableColumns.id == id).limit(1)) else { + return nil + } + do { + let tx: PendingTransaction = try row.decode() + return tx + } catch { + throw StorageError.operationFailed + } } func getAll() throws -> [PendingTransactionEntity] { - [] + let allTxs: [PendingTransaction] = try dbProvider.connection().prepare(table).map({ row in + try row.decode() + }) + return allTxs } } diff --git a/ZcashLightClientKit/DAO/TransactionBuilder.swift b/ZcashLightClientKit/DAO/TransactionBuilder.swift index 0f2a4262..d48955ca 100644 --- a/ZcashLightClientKit/DAO/TransactionBuilder.swift +++ b/ZcashLightClientKit/DAO/TransactionBuilder.swift @@ -76,7 +76,7 @@ struct TransactionBuilder { blockTimeInSeconds: TimeInterval(integerLiteral: blockTimeInSeconds), transactionIndex: Int(transactionIndex), raw: raw, - id: UInt(id), + id: Int(id), value: Int(value), memo: memo, rawTransactionId: transactionId) @@ -110,7 +110,7 @@ struct TransactionBuilder { blockTimeInSeconds: TimeInterval(integerLiteral: blockTimeInSeconds), transactionIndex: Int(transactionIndex), raw: nil, - id: UInt(id), + id: Int(id), value: Int(value), memo: memo, rawTransactionId: transactionId) diff --git a/ZcashLightClientKit/DAO/TransactionDao.swift b/ZcashLightClientKit/DAO/TransactionDao.swift index d6dbdb8f..0ff7d17f 100644 --- a/ZcashLightClientKit/DAO/TransactionDao.swift +++ b/ZcashLightClientKit/DAO/TransactionDao.swift @@ -36,7 +36,7 @@ struct ConfirmedTransaction: ConfirmedTransactionEntity { var blockTimeInSeconds: TimeInterval var transactionIndex: Int var raw: Data? - var id: UInt + var id: Int? var value: Int var memo: Data? var rawTransactionId: Data? @@ -45,12 +45,12 @@ struct ConfirmedTransaction: ConfirmedTransactionEntity { class TransactionSQLDAO: TransactionRepository { struct TableStructure { - static var id = Expression(Transaction.CodingKeys.id.rawValue) + static var id = Expression(Transaction.CodingKeys.id.rawValue) static var transactionId = Expression(Transaction.CodingKeys.transactionId.rawValue) static var created = Expression(Transaction.CodingKeys.created.rawValue) static var txIndex = Expression(Transaction.CodingKeys.transactionIndex.rawValue) - static var expiryHeight = Expression(Transaction.CodingKeys.expiryHeight.rawValue) - static var minedHeight = Expression(Transaction.CodingKeys.minedHeight.rawValue) + static var expiryHeight = Expression(Transaction.CodingKeys.expiryHeight.rawValue) + static var minedHeight = Expression(Transaction.CodingKeys.minedHeight.rawValue) static var raw = Expression(Transaction.CodingKeys.raw.rawValue) } @@ -70,8 +70,8 @@ class TransactionSQLDAO: TransactionRepository { try dbProvider.connection().scalar(transactions.filter(TableStructure.minedHeight == nil).count) } - func findBy(id: Int64) throws -> TransactionEntity? { - let query = transactions.filter(TableStructure.id == Int64(id)).limit(1) + func findBy(id: Int) throws -> TransactionEntity? { + let query = transactions.filter(TableStructure.id == id).limit(1) let sequence = try dbProvider.connection().prepare(query) let entity: Transaction? = try sequence.map({ try $0.decode() }).first return entity diff --git a/ZcashLightClientKit/Entity/PendingTransactionEntity.swift b/ZcashLightClientKit/Entity/PendingTransactionEntity.swift index adf827c6..d6a0450e 100644 --- a/ZcashLightClientKit/Entity/PendingTransactionEntity.swift +++ b/ZcashLightClientKit/Entity/PendingTransactionEntity.swift @@ -15,7 +15,7 @@ public protocol PendingTransactionEntity: SignedTransactionEntity, AbstractTrans var cancelled: Int { get set } var encodeAttempts: Int { get set } var submitAttempts: Int { get set } - var errorMesssage: String? { get set } + var errorMessage: String? { get set } var errorCode: Int? { get set } var createTime: TimeInterval { get set } @@ -47,7 +47,7 @@ public extension PendingTransactionEntity { } var isFailedSubmit: Bool { - errorMesssage != nil || (errorCode != nil && (errorCode ?? 0) < 0) + errorMessage != nil || (errorCode != nil && (errorCode ?? 0) < 0) } var isFailure: Bool { @@ -72,6 +72,6 @@ public extension PendingTransactionEntity { } var isSubmitSuccess: Bool { - submitAttempts > 0 && (errorCode != nil && (errorCode ?? -1) >= 0) && errorMesssage == nil + submitAttempts > 0 && (errorCode != nil && (errorCode ?? -1) >= 0) && errorMessage == nil } } diff --git a/ZcashLightClientKit/Entity/TransactionEntity.swift b/ZcashLightClientKit/Entity/TransactionEntity.swift index d355c947..fa4eb55e 100644 --- a/ZcashLightClientKit/Entity/TransactionEntity.swift +++ b/ZcashLightClientKit/Entity/TransactionEntity.swift @@ -43,7 +43,7 @@ public extension TransactionEntity { } public protocol AbstractTransaction { - var id: UInt { get set } + var id: Int? { get set } var value: Int { get set } var memo: Data? { get set } } diff --git a/ZcashLightClientKit/Repository/PendingTransactionRepository.swift b/ZcashLightClientKit/Repository/PendingTransactionRepository.swift index a055e327..540539f6 100644 --- a/ZcashLightClientKit/Repository/PendingTransactionRepository.swift +++ b/ZcashLightClientKit/Repository/PendingTransactionRepository.swift @@ -8,10 +8,10 @@ import Foundation protocol PendingTransactionRepository { - func create(_ transaction: PendingTransactionEntity) throws -> Int64 + func create(_ transaction: PendingTransactionEntity) throws -> Int func update(_ transaction: PendingTransactionEntity) throws func delete(_ transaction: PendingTransactionEntity) throws func cancel(_ transaction: PendingTransactionEntity) throws - func find(by id: Int64) throws -> PendingTransactionEntity? + func find(by id: Int) throws -> PendingTransactionEntity? func getAll() throws -> [PendingTransactionEntity] } diff --git a/ZcashLightClientKit/Repository/TransactionRepository.swift b/ZcashLightClientKit/Repository/TransactionRepository.swift index c9b35adf..3bca5239 100644 --- a/ZcashLightClientKit/Repository/TransactionRepository.swift +++ b/ZcashLightClientKit/Repository/TransactionRepository.swift @@ -14,7 +14,7 @@ enum TransactionRepositoryError: Error { protocol TransactionRepository { func countAll() throws -> Int func countUnmined() throws -> Int - func findBy(id: Int64) throws -> TransactionEntity? + func findBy(id: Int) throws -> TransactionEntity? func findBy(rawId: Data) throws -> TransactionEntity? func findAllSentTransactions(limit: Int) throws -> [ConfirmedTransactionEntity]? func findAllReceivedTransactions(limit: Int) throws -> [ConfirmedTransactionEntity]? diff --git a/ZcashLightClientKit/Service/LightWalletGRPCService.swift b/ZcashLightClientKit/Service/LightWalletGRPCService.swift index 637a173d..e831eafa 100644 --- a/ZcashLightClientKit/Service/LightWalletGRPCService.swift +++ b/ZcashLightClientKit/Service/LightWalletGRPCService.swift @@ -51,6 +51,24 @@ public class LightWalletGRPCService { } extension LightWalletGRPCService: LightWalletService { + public func submit(spendTransaction: Data, result: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + + guard let self = self else { return } + + do { + let response = try self.compactTxStreamer.sendTransaction(RawTransaction(serializedData: spendTransaction)) + result(.success(response)) + } catch { + result(.failure(LightWalletServiceError.genericError(error: error))) + } + } + } + + public func submit(spendTransaction: Data) throws -> LightWalletServiceResponse { + try compactTxStreamer.sendTransaction(RawTransaction(serializedData: spendTransaction)) + } + public func blockRange(_ range: CompactBlockRange) throws -> [ZcashCompactBlock] { var blocks = [ZcashCompactBlock]() @@ -142,3 +160,4 @@ extension LightWalletGRPCService: LightWalletService { } } + diff --git a/ZcashLightClientKit/Service/LightWalletService.swift b/ZcashLightClientKit/Service/LightWalletService.swift index 7a9d4819..e66f5ae5 100644 --- a/ZcashLightClientKit/Service/LightWalletService.swift +++ b/ZcashLightClientKit/Service/LightWalletService.swift @@ -8,11 +8,13 @@ import Foundation import SwiftGRPC - +import SwiftProtobuf public enum LightWalletServiceError: Error { case generalError case failed(statusCode: StatusCode) case invalidBlock + case sentFailed(sendResponse: LightWalletServiceResponse) + case genericError(error: Error) } extension LightWalletServiceError: Equatable { @@ -40,11 +42,28 @@ extension LightWalletServiceError: Equatable { default: return false } + case .sentFailed(let sendResponse): + switch rhs { + case .sentFailed(let response): + return response.errorCode == sendResponse.errorCode + default: + return false + } + case .genericError(_): + return false } } } +public protocol LightWalletServiceResponse { + var errorCode: Int32 { get } + var errorMessage: String { get } + var unknownFields: SwiftProtobuf.UnknownStorage { get } +} + +extension SendResponse: LightWalletServiceResponse {} + public protocol LightWalletService { /** Return the latest block height known to the service. @@ -79,4 +98,16 @@ public protocol LightWalletService { */ func blockRange(_ range: CompactBlockRange) throws -> [ZcashCompactBlock] + + /** + Submits a raw transaction over lightwalletd. Non-Blocking + */ + func submit(spendTransaction: Data, result: @escaping(Result) -> Void) + + /** + Submits a raw transaction over lightwalletd. Blocking + */ + + func submit(spendTransaction: Data) throws -> LightWalletServiceResponse + } diff --git a/ZcashLightClientKit/Transaction/PersistentTransactionManager.swift b/ZcashLightClientKit/Transaction/PersistentTransactionManager.swift new file mode 100644 index 00000000..ee8f1c4a --- /dev/null +++ b/ZcashLightClientKit/Transaction/PersistentTransactionManager.swift @@ -0,0 +1,168 @@ +// +// PendingTransactionsManager.swift +// ZcashLightClientKit +// +// Created by Francisco Gindre on 11/26/19. +// + +import Foundation + +enum TransactionManagerError: Error { + case couldNotCreateSpend(toAddress: String, account: Int, zatoshi: Int) + case encodingFailed(tx: PendingTransactionEntity) + case updateFailed(tx: PendingTransactionEntity) + case notPending(tx: PendingTransactionEntity) + case cancelled(tx: PendingTransactionEntity) + case internalInconsistency(tx: PendingTransactionEntity) + case submitFailed(tx: PendingTransactionEntity, errorCode: Int) +} + +class PersistentTransactionManager: OutboundTransactionManager { + + var repository: PendingTransactionRepository + var encoder: TransactionEncoder + var service: LightWalletService + var queue: DispatchQueue + init(encoder: TransactionEncoder, service: LightWalletService, repository: PendingTransactionRepository) { + self.repository = repository + self.encoder = encoder + self.service = service + self.queue = DispatchQueue.init(label: "PersistentTransactionManager.serial.queue") + } + + func initSpend(zatoshi: Int, toAddress: String, memo: String?, from accountIndex: Int) throws -> PendingTransactionEntity { + + guard let insertedTx = try repository.find(by: try repository.create(PendingTransaction(value: zatoshi, toAddress: toAddress, memo: memo, account: accountIndex))) else { + throw TransactionManagerError.couldNotCreateSpend(toAddress: toAddress, account: accountIndex, zatoshi: zatoshi) + } + print("pending transaction \(String(describing: insertedTx.id)) created") + return insertedTx + } + + func encode(spendingKey: String, pendingTransaction: PendingTransactionEntity, result: @escaping (Result) -> Void) { + // FIX: change to async when librustzcash is updated to v6 + queue.sync { [weak self] in + guard let self = self else { return } + do { + let encodedTransaction = try self.encoder.createTransaction(spendingKey: spendingKey, zatoshi: pendingTransaction.value, to: pendingTransaction.toAddress, memo: pendingTransaction.memo?.asZcashTransactionMemo(), from: pendingTransaction.accountIndex) + var pending = pendingTransaction + pending.encodeAttempts = 1 + pending.raw = encodedTransaction.raw + pending.rawTransactionId = encodedTransaction.raw + try self.repository.update(pending) + + DispatchQueue.main.async { + result(.success(pending)) + } + } catch StorageError.updateFailed { + DispatchQueue.main.async { + result(.failure(TransactionManagerError.updateFailed(tx: pendingTransaction))) + } + } catch { + + DispatchQueue.main.async { + result(.failure(error)) + } + } + } + } + + func submit(pendingTransaction: PendingTransactionEntity, result: @escaping (Result) -> Void) { + + guard let txId = pendingTransaction.id else { + result(.failure(TransactionManagerError.notPending(tx: pendingTransaction)))// this transaction is not stored + return + } + // FIX: change to async when librustzcash is updated to v6 + queue.sync { [weak self] in + guard let self = self else { return } + do { + guard let storedTx = try self.repository.find(by: txId) else { + result(.failure(TransactionManagerError.notPending(tx: pendingTransaction))) + return + } + + guard !storedTx.isCancelled else { + print("ignoring cancelled transaction \(storedTx)") + result(.failure(TransactionManagerError.cancelled(tx: storedTx))) + return + } + + guard let raw = storedTx.raw else { + print("INCONSISTENCY: attempt to send pending transaction \(txId) that has not raw data") + result(.failure(TransactionManagerError.internalInconsistency(tx: storedTx))) + return + } + let response = try self.service.submit(spendTransaction: raw) + + let tx = try self.update(transaction: storedTx, on: response) + guard response.errorCode >= 0 else { + result(.failure(TransactionManagerError.submitFailed(tx: tx, errorCode: Int(response.errorCode)))) + return + } + + result(.success(tx)) + } catch { + result(.failure(error)) + } + } + } + + func applyMinedHeight(pendingTransaction: PendingTransactionEntity, minedHeight: BlockHeight) throws -> PendingTransactionEntity { + + guard let id = pendingTransaction.id else { + throw TransactionManagerError.internalInconsistency(tx: pendingTransaction) + } + + guard var tx = try repository.find(by: id) else { + throw TransactionManagerError.notPending(tx: pendingTransaction) + } + + tx.minedHeight = minedHeight + + do { + try repository.update(tx) + } catch { + throw TransactionManagerError.updateFailed(tx: tx) + } + return tx + } + + func monitorChanges(byId: Int, observer: Any) { + // TODO: Implement this + } + + func cancel(pendingTransaction: PendingTransactionEntity) -> Bool { + guard let id = pendingTransaction.id else { return false } + + guard let tx = try? repository.find(by: id) else { return false } + + guard !tx.isSubmitted else { return false } + + guard (try? repository.cancel(tx)) != nil else { return false } + return true + } + + func allPendingTransactions() throws -> [PendingTransactionEntity]? { + try repository.getAll() + } + + + // MARK: other functions + private func updateOnFailure(tx: PendingTransactionEntity, error: Error) throws { + var pending = tx + pending.errorMessage = error.localizedDescription + pending.encodeAttempts = tx.encodeAttempts + 1 + try self.repository.update(pending) + } + + private func update(transaction: PendingTransactionEntity, on sendResponse: LightWalletServiceResponse) throws -> PendingTransactionEntity { + var tx = transaction + + let error = sendResponse.errorCode < 0 + tx.errorCode = Int(sendResponse.errorCode) + tx.errorMessage = error ? sendResponse.errorMessage : nil + try repository.update(tx) + return tx + } +} diff --git a/ZcashLightClientKit/Transaction/TransactionEncoder.swift b/ZcashLightClientKit/Transaction/TransactionEncoder.swift index 5a215502..abb41906 100644 --- a/ZcashLightClientKit/Transaction/TransactionEncoder.swift +++ b/ZcashLightClientKit/Transaction/TransactionEncoder.swift @@ -10,8 +10,8 @@ import Foundation typealias TransactionEncoderResultBlock = (_ result: Result) -> Void public enum TransactionEncoderError: Error { - case notFound(transactionId: Int64) - case NotEncoded(transactionId: Int64) + case notFound(transactionId: Int) + case NotEncoded(transactionId: Int) case missingParams case spendingKeyWrongNetwork } @@ -24,7 +24,7 @@ protocol TransactionEncoder { double-bangs for things). Blocking */ - func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction + func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction /** Creates a transaction, throwing an exception whenever things are missing. When the provided wallet implementation @@ -32,6 +32,6 @@ protocol TransactionEncoder { double-bangs for things). Non-blocking */ - func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) + func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) } diff --git a/ZcashLightClientKit/Transaction/TransactionManager.swift b/ZcashLightClientKit/Transaction/TransactionManager.swift new file mode 100644 index 00000000..1a828b28 --- /dev/null +++ b/ZcashLightClientKit/Transaction/TransactionManager.swift @@ -0,0 +1,34 @@ +// +// TransactionManager.swift +// ZcashLightClientKit +// +// Created by Francisco Gindre on 11/26/19. +// + +import Foundation + + +/** + Manage outbound transactions with the main purpose of reporting which ones are still pending, + particularly after failed attempts or dropped connectivity. The intent is to help see outbound + transactions through to completion. +*/ + +protocol OutboundTransactionManager { + func initSpend(zatoshi: Int, toAddress: String, memo: String?, from accountIndex: Int) throws -> PendingTransactionEntity + + func encode(spendingKey: String, pendingTransaction: PendingTransactionEntity, result: @escaping (Result) -> Void) + + func submit(pendingTransaction: PendingTransactionEntity, result: @escaping (Result) -> Void) + + func applyMinedHeight(pendingTransaction: PendingTransactionEntity, minedHeight: BlockHeight) throws -> PendingTransactionEntity + + func monitorChanges(byId: Int, observer: Any) // check this out. code smell + + /** + Attempts to Cancel a transaction. Returns true if successful + */ + func cancel(pendingTransaction: PendingTransactionEntity) -> Bool + + func allPendingTransactions() throws -> [PendingTransactionEntity]? +} diff --git a/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift b/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift index 9b72fb96..0d4ac6dc 100644 --- a/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift +++ b/ZcashLightClientKit/Transaction/WalletTransactionEncoder.swift @@ -8,16 +8,19 @@ import Foundation class WalletTransactionEncoder: TransactionEncoder { + var rustBackend: ZcashRustBackend.Type var repository: TransactionRepository var initializer: Initializer + var queue: DispatchQueue init(rust: ZcashRustBackend.Type, repository: TransactionRepository, initializer: Initializer) { self.rustBackend = rust self.repository = repository self.initializer = initializer + self.queue = DispatchQueue(label: "wallet.transaction.encoder.serial.queue") } - func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction { + func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int) throws -> EncodedTransaction { let txId = try createSpend(spendingKey: spendingKey, zatoshi: zatoshi, to: to, memo: memo, from: accountIndex) @@ -35,9 +38,9 @@ class WalletTransactionEncoder: TransactionEncoder { } } - func createTransaction(spendingKey: String, zatoshi: Int64, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) { + func createTransaction(spendingKey: String, zatoshi: Int, to: String, memo: String?, from accountIndex: Int, result: @escaping TransactionEncoderResultBlock) { - DispatchQueue.global(qos: .userInitiated).async { [weak self] in + queue.async { [weak self] in guard let self = self else { return } do { result(.success(try self.createTransaction(spendingKey: spendingKey, zatoshi: zatoshi, to: to, memo: memo, from: accountIndex))) @@ -47,7 +50,7 @@ class WalletTransactionEncoder: TransactionEncoder { } } - func createSpend(spendingKey: String, zatoshi: Int64, to address: String, memo: String?, from accountIndex: Int) throws -> Int64 { + func createSpend(spendingKey: String, zatoshi: Int, to address: String, memo: String?, from accountIndex: Int) throws -> Int { guard ensureParams(spend: initializer.spendParamsURL, output: initializer.spendParamsURL), let spend = URL(string: initializer.spendParamsURL.path), let output = URL(string: initializer.outputParamsURL.path) else { throw TransactionEncoderError.missingParams @@ -60,7 +63,7 @@ class WalletTransactionEncoder: TransactionEncoder { throw rustBackend.lastError() ?? RustWeldingError.genericError(message: "create spend failed") } - return txId + return Int(txId) } func ensureParams(spend: URL, output: URL) -> Bool { diff --git a/ZcashLightClientKitTests/OutboundTransactionManagerTests.swift b/ZcashLightClientKitTests/OutboundTransactionManagerTests.swift new file mode 100644 index 00000000..037913ca --- /dev/null +++ b/ZcashLightClientKitTests/OutboundTransactionManagerTests.swift @@ -0,0 +1,219 @@ +// +// OutboundTransactionManagerTests.swift +// ZcashLightClientKit-Unit-Tests +// +// Created by Francisco Gindre on 11/26/19. +// + +import XCTest +@testable import ZcashLightClientKit +class OutboundTransactionManagerTests: XCTestCase { + + var transactionManager: OutboundTransactionManager! + var encoder: TransactionEncoder! + var pendingRespository: PendingTransactionSQLDAO! + var dataDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedDataDbURL()!) + var cacheDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedCacheDbURL()!) + var pendingDbhandle = TestDbHandle(originalDb: try! TestDbBuilder.pendingTransactionsDbURL()) + var service: LightWalletService! + var initializer: Initializer! + let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn" + let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6" + let zpend: Int = 500_000 + + + override func setUp() { + + try! dataDbHandle.setUp() + try! cacheDbHandle.setUp() + pendingRespository = PendingTransactionSQLDAO(dbProvider: pendingDbhandle.connectionProvider(readwrite: true)) + + try! pendingRespository.createrTableIfNeeded() + + initializer = Initializer(cacheDbURL: cacheDbHandle.readWriteDb, dataDbURL: dataDbHandle.readWriteDb, endpoint: LightWalletEndpointBuilder.default, spendParamsURL: try! __spendParamsURL(), outputParamsURL: try! __outputParamsURL()) + + encoder = WalletTransactionEncoder(rust: ZcashRustBackend.self, repository: TestDbBuilder.transactionRepository()!, initializer: initializer) + transactionManager = PersistentTransactionManager(encoder: encoder, service: MockLightWalletService(latestBlockHeight: 620999), repository: pendingRespository) + + + } + + override func tearDown() { + transactionManager = nil + encoder = nil + service = nil + initializer = nil + pendingRespository = nil + dataDbHandle.dispose() + cacheDbHandle.dispose() + pendingDbhandle.dispose() + } + + func testInitSpend() { + // This is an example of a functional test case. + // Use XCTAssert and related functions to verify your tests produce the correct results. + + var tx: PendingTransactionEntity? + + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + + guard let pendingTx = tx else { + XCTFail("failed to create pending transaction") + return + } + + XCTAssertEqual(pendingTx.toAddress, recipientAddress) + XCTAssertEqual(pendingTx.memo, nil) + XCTAssertEqual(pendingTx.value, zpend) + + } + + func testEncodeSpend() { + let expect = XCTestExpectation(description: self.description) + + var tx: PendingTransactionEntity? + + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + + guard let pendingTx = tx else { + XCTFail("failed to create pending transaction") + return + } + + transactionManager.encode(spendingKey: spendingKey, pendingTransaction: pendingTx) { (result) in + expect.fulfill() + + switch result { + case .failure(let error): + XCTFail("failed with error: \(error)") + case .success(let tx): + XCTAssertEqual(tx.id, pendingTx.id) + } + } + wait(for: [expect], timeout: 120) + + } + + + func testSubmit() { + + let encodeExpect = XCTestExpectation(description: "encode") + + let submitExpect = XCTestExpectation(description: "submit") + var tx: PendingTransactionEntity? + + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + + guard let pendingTx = tx else { + XCTFail("failed to create pending transaction") + return + } + + var encodedTx: PendingTransactionEntity? + transactionManager.encode(spendingKey: spendingKey, pendingTransaction: pendingTx) { (result) in + encodeExpect.fulfill() + + switch result { + case .failure(let error): + XCTFail("failed with error: \(error)") + case .success(let tx): + XCTAssertEqual(tx.id, pendingTx.id) + encodedTx = tx + } + } + wait(for: [encodeExpect], timeout: 500) + + guard let submittableTx = encodedTx else { + XCTFail("failed to encode tx") + return + } + + + transactionManager.submit(pendingTransaction: submittableTx) { (result) in + submitExpect.fulfill() + switch result { + case .failure(let error): + XCTFail("submission failed with error: \(error)") + case .success(let successfulTx): + XCTAssertEqual(submittableTx.id, successfulTx.id) + } + + } + wait(for: [submitExpect], timeout: 5) + } + + + func testApplyMinedHeight() { + var tx: PendingTransactionEntity? + + let minedHeight = 789_000 + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + + guard let pendingTx = tx else { + XCTFail("failed to create pending transaction") + return + } + + var minedTransaction: PendingTransactionEntity? + + XCTAssertNoThrow(try { minedTransaction = try transactionManager.applyMinedHeight(pendingTransaction: pendingTx, minedHeight: minedHeight)}()) + + guard let minedTx = minedTransaction else { + XCTFail("failed to apply mined height") + return + } + XCTAssertTrue(minedTx.isMined) + XCTAssertEqual(minedTx.minedHeight, minedHeight) + + } + + func testCancel() { + + var tx: PendingTransactionEntity? + + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + + guard let pendingTx = tx else { + XCTFail("failed to create pending transaction") + return + } + + let cancellationResult = transactionManager.cancel(pendingTransaction: pendingTx) + + guard let id = pendingTx.id else { + XCTFail("transaction with no id") + return + } + guard let retrievedTransaction = try! pendingRespository.find(by: id) else { + XCTFail("failed to retrieve previously created transation") + return + } + + XCTAssertEqual(cancellationResult, retrievedTransaction.isCancelled) + + } + + + func testAllPendingTransactions() { + + let txCount = 100 + for i in 0 ..< txCount { + var tx: PendingTransactionEntity? + + XCTAssertNoThrow(try { tx = try transactionManager.initSpend(zatoshi: zpend, toAddress: recipientAddress, memo: nil, from: 0) }()) + guard tx != nil else { + XCTFail("failed to create pending transaction \(i)") + return + } + + } + + guard let allPending = try! transactionManager.allPendingTransactions() else { + XCTFail("failed to retrieve all pending transactions") + return + } + + XCTAssertEqual(allPending.count, txCount) + + } +} diff --git a/ZcashLightClientKitTests/PendingTransactionRepositoryTests.swift b/ZcashLightClientKitTests/PendingTransactionRepositoryTests.swift new file mode 100644 index 00000000..2097432f --- /dev/null +++ b/ZcashLightClientKitTests/PendingTransactionRepositoryTests.swift @@ -0,0 +1,174 @@ +// +// PendingTransactionRepositoryTests.swift +// ZcashLightClientKit-Unit-Tests +// +// Created by Francisco Gindre on 11/27/19. +// + +import XCTest +@testable import ZcashLightClientKit +class PendingTransactionRepositoryTests: XCTestCase { + + var pendingRepository: PendingTransactionRepository! + + let dbUrl = try! TestDbBuilder.pendingTransactionsDbURL() + + let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6" + override func setUp() { + cleanUpDb() + let dao = PendingTransactionSQLDAO(dbProvider: SimpleConnectionProvider(path: try! TestDbBuilder.pendingTransactionsDbURL().absoluteString)) + try! dao.createrTableIfNeeded() + pendingRepository = dao + + } + + override func tearDown() { + cleanUpDb() + } + + func cleanUpDb() { + try? FileManager.default.removeItem(at: TestDbBuilder.pendingTransactionsDbURL()) + } + + func testCreate() { + + let tx = createAndStoreMockedTransaction() + + guard let id = tx.id, id >= 0 else { + XCTFail("failed to create mocked transaction that was just inserted") + return + } + + var expectedTx: PendingTransactionEntity? + XCTAssertNoThrow(try { expectedTx = try pendingRepository.find(by: id)}()) + + guard let expected = expectedTx else { + XCTFail("failed to retrieve mocked transaction by id \(id) that was just inserted") + return + } + + XCTAssertEqual(tx.accountIndex, expected.accountIndex) + XCTAssertEqual(tx.value, expected.value) + XCTAssertEqual(tx.toAddress, expected.toAddress) + } + + func testFindById() { + let tx = createAndStoreMockedTransaction() + + var expected: PendingTransactionEntity? + + guard let id = tx.id else { + XCTFail("transaction with no id") + return + } + XCTAssertNoThrow(try { expected = try pendingRepository.find(by: id)}()) + + XCTAssertNotNil(expected) + } + + func testCancel() { + let tx = createAndStoreMockedTransaction() + guard let id = tx.id else { + XCTFail("transaction with no id") + return + } + guard id >= 0 else { + XCTFail("failed to create mocked transaction that was just inserted") + return + } + + XCTAssertNoThrow(try pendingRepository.cancel(tx)) + } + + func testDelete() { + let tx = createAndStoreMockedTransaction() + guard let id = tx.id else { + XCTFail("transaction with no id") + return + } + guard id >= 0 else { + XCTFail("failed to create mocked transaction that was just inserted") + return + } + + XCTAssertNoThrow(try pendingRepository.delete(tx)) + + var unexpectedTx: PendingTransactionEntity? + + XCTAssertNoThrow(try { unexpectedTx = try pendingRepository.find(by: id) }()) + + XCTAssertNil(unexpectedTx) + } + + func testGetAll() { + var mockTransactions = [PendingTransactionEntity]() + for _ in 1...100 { + mockTransactions.append(createAndStoreMockedTransaction()) + } + + var all: [PendingTransactionEntity]? + + XCTAssertNoThrow(try { all = try pendingRepository.getAll() }()) + + guard let allTxs = all else { + XCTFail("failed to get all transactions") + return + } + + XCTAssertEqual(mockTransactions.count, allTxs.count) + + } + + func testUpdate() { + let newAccountIndex = 1 + let newValue: Int = 123_456 + let tx = createAndStoreMockedTransaction() + guard let id = tx.id else { + XCTFail("transaction with no id") + return + } + var stored: PendingTransactionEntity? + + XCTAssertNoThrow(try { stored = try pendingRepository.find(by: id)}()) + + guard stored != nil else { + XCTFail("failed to store tx") + return + } + + stored!.accountIndex = newAccountIndex + stored!.value = newValue + + XCTAssertNoThrow(try pendingRepository.update(stored!)) + + guard let updatedTransaction = try? pendingRepository.find(by: stored!.id!) else { + XCTFail("failed to retrieve updated transaction with id: \(stored!.id!)") + return + } + + XCTAssertEqual(updatedTransaction.value, newValue) + XCTAssertEqual(updatedTransaction.accountIndex, newAccountIndex) + XCTAssertEqual(updatedTransaction.toAddress, stored!.toAddress) + } + + func createAndStoreMockedTransaction() -> PendingTransactionEntity { + var tx = mockTransaction() + var id: Int? + + XCTAssertNoThrow(try { id = try pendingRepository.create(tx) }()) + tx.id = Int(id ?? -1) + return tx + } + + func testPerformanceExample() { + // This is an example of a performance test case. + self.measure { + _ = try! pendingRepository.getAll() + } + } + + private func mockTransaction() -> PendingTransactionEntity { + PendingTransaction(value: Int.random(in: 1 ... 1_000_000), toAddress: recipientAddress, memo: nil, account: 0) + } + +} diff --git a/ZcashLightClientKitTests/WalletTransactionEncoderTests.swift b/ZcashLightClientKitTests/WalletTransactionEncoderTests.swift index 32c2ab0a..62ce6af4 100644 --- a/ZcashLightClientKitTests/WalletTransactionEncoderTests.swift +++ b/ZcashLightClientKitTests/WalletTransactionEncoderTests.swift @@ -17,7 +17,7 @@ class WalletTransactionEncoderTests: XCTestCase { var initializer: Initializer! let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn" let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6" - let zpend: Int64 = 500_000 + let zpend: Int = 500_000 override func setUp() { try! dataDbHandle.setUp() @@ -54,7 +54,7 @@ class WalletTransactionEncoderTests: XCTestCase { XCTAssert(initializer.getBalance() >= zpend) - var spendId: Int64? + var spendId: Int? XCTAssertNoThrow(try { spendId = try transactionEncoder.createSpend(spendingKey: self.spendingKey, zatoshi: self.zpend, to: self.recipientAddress, memo: nil, from: 0) }()) diff --git a/ZcashLightClientKitTests/ZcashRustBackendTests.swift b/ZcashLightClientKitTests/ZcashRustBackendTests.swift index 8afc101a..da2af031 100644 --- a/ZcashLightClientKitTests/ZcashRustBackendTests.swift +++ b/ZcashLightClientKitTests/ZcashRustBackendTests.swift @@ -11,16 +11,20 @@ import XCTest class ZcashRustBackendTests: XCTestCase { var dbData: URL! - + var dataDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedDataDbURL()!) + var cacheDbHandle = TestDbHandle(originalDb: TestDbBuilder.prePopulatedCacheDbURL()!) + let spendingKey = "secret-extended-key-test1qvpevftsqqqqpqy52ut2vv24a2qh7nsukew7qg9pq6djfwyc3xt5vaxuenshp2hhspp9qmqvdh0gs2ljpwxders5jkwgyhgln0drjqaguaenfhehz4esdl4kwlm5t9q0l6wmzcrvcf5ed6dqzvct3e2ge7f6qdvzhp02m7sp5a0qjssrwpdh7u6tq89hl3wchuq8ljq8r8rwd6xdwh3nry9at80z7amnj3s6ah4jevnvfr08gxpws523z95g6dmn4wm6l3658kd4xcq9rc0qn" + let recipientAddress = "ztestsapling1ctuamfer5xjnnrdr3xdazenljx0mu0gutcf9u9e74tr2d3jwjnt0qllzxaplu54hgc2tyjdc2p6" + let zpend: Int = 500_000 override func setUp() { dbData = try! __dataDbURL() - + try? dataDbHandle.setUp() } override func tearDown() { try? FileManager.default.removeItem(at: dbData!) - + dataDbHandle.dispose() } func testInitAndGetAddress() { @@ -60,4 +64,11 @@ class ZcashRustBackendTests: XCTestCase { XCTAssertTrue(ZcashRustBackend.scanBlocks(dbCache: cacheDb, dbData: dbData)) } + + func testSendToAddress() { + + let tx = try! ZcashRustBackend.sendToAddress(dbData: dataDbHandle.readWriteDb, account: 0, extsk: spendingKey, to: recipientAddress, value: Int64(zpend), memo: nil, spendParams: URL(string: __spendParamsURL().path)!, outputParams: URL(string: __outputParamsURL().path)!) + XCTAssert(tx > 0) + XCTAssertNil(ZcashRustBackend.lastError()) + } } diff --git a/ZcashLightClientKitTests/utils/FakeService.swift b/ZcashLightClientKitTests/utils/FakeService.swift index 0c55dc62..ccc818b7 100644 --- a/ZcashLightClientKitTests/utils/FakeService.swift +++ b/ZcashLightClientKitTests/utils/FakeService.swift @@ -7,7 +7,15 @@ // import Foundation +import SwiftProtobuf @testable import ZcashLightClientKit + +struct LightWalletServiceMockResponse: LightWalletServiceResponse { + var errorCode: Int32 + var errorMessage: String + var unknownFields: UnknownStorage +} + class MockLightWalletService: LightWalletService { private var service = LightWalletGRPCService(channel: ChannelProvider().channel()) @@ -34,5 +42,13 @@ class MockLightWalletService: LightWalletService { try self.service.blockRange(range) } + func submit(spendTransaction: Data, result: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 1) { + result(.success(LightWalletServiceMockResponse(errorCode: 0, errorMessage: "", unknownFields: UnknownStorage()))) + } + } + func submit(spendTransaction: Data) throws -> LightWalletServiceResponse { + return LightWalletServiceMockResponse(errorCode: 0, errorMessage: "", unknownFields: UnknownStorage()) + } } diff --git a/ZcashLightClientKitTests/utils/Stubs.swift b/ZcashLightClientKitTests/utils/Stubs.swift index bf762d62..9871870e 100644 --- a/ZcashLightClientKitTests/utils/Stubs.swift +++ b/ZcashLightClientKitTests/utils/Stubs.swift @@ -31,6 +31,19 @@ class AwfulLightWalletService: LightWalletService { } } + func submit(spendTransaction: Data, result: @escaping(Result) -> Void) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + result(.failure(LightWalletServiceError.generalError)) + } + } + + /** + Submits a raw transaction over lightwalletd. Blocking + */ + + func submit(spendTransaction: Data) throws -> LightWalletServiceResponse { + throw LightWalletServiceError.generalError + } } class MockRustBackend: ZcashRustBackendWelding { diff --git a/ZcashLightClientKitTests/utils/TestDbBuilder.swift b/ZcashLightClientKitTests/utils/TestDbBuilder.swift index f31b20b3..2bf81938 100644 --- a/ZcashLightClientKitTests/utils/TestDbBuilder.swift +++ b/ZcashLightClientKitTests/utils/TestDbBuilder.swift @@ -50,6 +50,9 @@ class TestDbBuilder { return compactBlockDao } + static func pendingTransactionsDbURL() throws -> URL { + try __documentsDirectory().appendingPathComponent("pending.db") + } static func prePopulatedCacheDbURL() -> URL? { Bundle(for: TestDbBuilder.self).url(forResource: "cache", withExtension: "db") }