diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index 6fbe780b..ff569465 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -17,7 +17,7 @@ jobs: permissions: contents: read - runs-on: macos-14 + runs-on: macos-15 steps: - uses: actions/checkout@a12a3943b4bdde767164f792f33f40b04645d846 diff --git a/CHANGELOG.md b/CHANGELOG.md index 47138a3a..0eb228bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `SDKSynchronizer.redactPCZTForSigner`: Decrease the size of a PCZT for sending to a signer. - `SDKSynchronizer.PCZTRequiresSaplingProofs`: Check whether the Sapling parameters are required for a given PCZT. +## Updated +- Methods returning an array of `ZcashTransaction.Overview` try to evaluate transaction's missing blockTime. This typically applies to an expired transaction. + # 2.2.8 - 2025-01-10 ## Added diff --git a/Sources/ZcashLightClientKit/DAO/BlockDao.swift b/Sources/ZcashLightClientKit/DAO/BlockDao.swift new file mode 100644 index 00000000..0b3e6f94 --- /dev/null +++ b/Sources/ZcashLightClientKit/DAO/BlockDao.swift @@ -0,0 +1,65 @@ +// BlockDao.swift +// ZcashLightClientKit +// +// Created by Lukas Korba on 2025-01-25. +// + +import Foundation +import SQLite + +protocol BlockDao { + func block(at height: BlockHeight) throws -> Block? +} + +struct Block: Codable { + enum CodingKeys: String, CodingKey { + case height + case time + } + + enum TableStructure { + static let height = SQLite.Expression(Block.CodingKeys.height.rawValue) + static let time = SQLite.Expression(Block.CodingKeys.time.rawValue) + } + + let height: BlockHeight + let time: Int + + static let table = Table("blocks") +} + +class BlockSQLDAO: BlockDao { + let dbProvider: ConnectionProvider + let table: Table + let height = SQLite.Expression("height") + + init(dbProvider: ConnectionProvider) { + self.dbProvider = dbProvider + self.table = Table("Blocks") + } + + /// - Throws: + /// - `blockDAOCantDecode` if block data loaded from DB can't be decoded to `Block` object. + /// - `blockDAOBlock` if sqlite query to load block metadata failed. + func block(at height: BlockHeight) throws -> Block? { + do { + return try dbProvider + .connection() + .prepare(Block.table.filter(Block.TableStructure.height == height).limit(1)) + .map { + do { + return try $0.decode() + } catch { + throw ZcashError.blockDAOCantDecode(error) + } + } + .first + } catch { + if let error = error as? ZcashError { + throw error + } else { + throw ZcashError.blockDAOBlock(error) + } + } + } +} diff --git a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift index 70c12e49..31c81eb5 100644 --- a/Sources/ZcashLightClientKit/DAO/TransactionDao.swift +++ b/Sources/ZcashLightClientKit/DAO/TransactionDao.swift @@ -14,14 +14,22 @@ class TransactionSQLDAO: TransactionRepository { static let memo = SQLite.Expression("memo") } + enum UserMetadata { + static let txid = SQLite.Expression("txid") + static let memoCount = SQLite.Expression("memo_count") + static let memo = SQLite.Expression("memo") + } + let dbProvider: ConnectionProvider + private let blockDao: BlockSQLDAO private let transactionsView = View("v_transactions") private let txOutputsView = View("v_tx_outputs") private let traceClosure: ((String) -> Void)? init(dbProvider: ConnectionProvider, traceClosure: ((String) -> Void)? = nil) { self.dbProvider = dbProvider + self.blockDao = BlockSQLDAO(dbProvider: dbProvider) self.traceClosure = traceClosure } @@ -38,7 +46,49 @@ class TransactionSQLDAO: TransactionRepository { func isInitialized() async throws -> Bool { true } + + func resolveMissingBlockTimes(for transactions: [ZcashTransaction.Overview]) async throws -> [ZcashTransaction.Overview] { + var transactionsCopy = transactions + + for i in 0.. [Data] { + let query = transactionsView + .join(txOutputsView, on: transactionsView[UserMetadata.txid] == txOutputsView[UserMetadata.txid]) + .filter(transactionsView[UserMetadata.memoCount] > 0) + .filter(txOutputsView[UserMetadata.memo].like("%\(searchTerm)%")) + + var txids: [Data] = [] + for row in try connection().prepare(query) { + let txidBlob = try row.get(txOutputsView[UserMetadata.txid]) + let txid = Data(blob: txidBlob) + txids.append(txid) + } + + return txids + } + + @DBActor + func blockForHeight(_ height: BlockHeight) async throws -> Block? { + try blockDao.block(at: height) + } + @DBActor func countAll() async throws -> Int { do { @@ -71,7 +121,9 @@ class TransactionSQLDAO: TransactionRepository { .filterQueryFor(kind: kind) .limit(limit, offset: offset) - return try await execute(query) { try ZcashTransaction.Overview(row: $0) } + let transactions: [ZcashTransaction.Overview] = try await execute(query) { try ZcashTransaction.Overview(row: $0) } + + return try await resolveMissingBlockTimes(for: transactions) } func find(in range: CompactBlockRange, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] { diff --git a/Sources/ZcashLightClientKit/Entity/TransactionEntity.swift b/Sources/ZcashLightClientKit/Entity/TransactionEntity.swift index f18be4a8..f86d5541 100644 --- a/Sources/ZcashLightClientKit/Entity/TransactionEntity.swift +++ b/Sources/ZcashLightClientKit/Entity/TransactionEntity.swift @@ -9,7 +9,7 @@ import Foundation import SQLite public enum ZcashTransaction { - public struct Overview { + public struct Overview: Equatable, Identifiable { /// Represents the transaction state based on current height of the chain, /// mined height and expiry height of a transaction. public enum State { @@ -43,9 +43,11 @@ public enum ZcashTransaction { } } } + + public var id: Data { rawID } public let accountUUID: AccountUUID - public let blockTime: TimeInterval? + public var blockTime: TimeInterval? public let expiryHeight: BlockHeight? public let fee: Zatoshi? public let index: Int? @@ -62,8 +64,8 @@ public enum ZcashTransaction { public let isExpiredUmined: Bool? } - public struct Output { - public enum Pool { + public struct Output: Equatable, Identifiable { + public enum Pool: Equatable { case transaparent case sapling case orchard @@ -82,6 +84,8 @@ public enum ZcashTransaction { } } + public var id: Data { rawID } + public let rawID: Data public let pool: Pool public let index: Int @@ -93,7 +97,7 @@ public enum ZcashTransaction { } /// Used when fetching blocks from the lightwalletd - struct Fetched { + struct Fetched: Equatable { public let rawID: Data public let minedHeight: UInt32? public let raw: Data diff --git a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift index 20d9c83f..8abdb4e4 100644 --- a/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift +++ b/Sources/ZcashLightClientKit/Repository/TransactionRepository.swift @@ -12,6 +12,7 @@ protocol TransactionRepository { func countAll() async throws -> Int func countUnmined() async throws -> Int func isInitialized() async throws -> Bool + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] func find(rawID: Data) async throws -> ZcashTransaction.Overview func find(offset: Int, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] func find(in range: CompactBlockRange, limit: Int, kind: TransactionKind) async throws -> [ZcashTransaction.Overview] diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index ac03a880..509e13be 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -72,7 +72,7 @@ public enum SynchronizerEvent { case minedTransaction(ZcashTransaction.Overview) // Sent when the synchronizer finds a mined transaction - case foundTransactions(_ transactions: [ZcashTransaction.Overview], _ inRange: CompactBlockRange) + case foundTransactions(_ transactions: [ZcashTransaction.Overview], _ inRange: CompactBlockRange?) // Sent when the synchronizer fetched utxos from lightwalletd attempted to store them. case storedUTXOs(_ inserted: [UnspentTransactionOutputEntity], _ skipped: [UnspentTransactionOutputEntity]) // Connection state to LightwalletEndpoint changed. @@ -355,6 +355,8 @@ public protocol Synchronizer: AnyObject { keySource: String? ) async throws -> AccountUUID + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] + /// Rescans the known blocks with the current keys. /// /// `rewind(policy:)` can be called anytime. If the sync process is in progress then it is stopped first. In this case, it make some significant diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index bd0372dc..a1b8e9ae 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -250,6 +250,8 @@ public class SDKSynchronizer: Synchronizer { } private func foundTransactions(transactions: [ZcashTransaction.Overview], in range: CompactBlockRange) { + guard !transactions.isEmpty else { return } + streamsUpdateQueue.async { [weak self] in self?.eventSubject.send(.foundTransactions(transactions, range)) } @@ -379,6 +381,11 @@ public class SDKSynchronizer: Synchronizer { var iterator = transactions.makeIterator() var submitFailed = false + // let clients know the transaction repository changed + if !transactions.isEmpty { + eventSubject.send(.foundTransactions(transactions, nil)) + } + return AsyncThrowingStream() { guard let transaction = iterator.next() else { return nil } @@ -447,6 +454,10 @@ public class SDKSynchronizer: Synchronizer { return submitTransactions(transactions) } + public func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] { + try await transactionRepository.fetchTxidsWithMemoContaining(searchTerm: searchTerm) + } + public func allReceivedTransactions() async throws -> [ZcashTransaction.Overview] { try await transactionRepository.findReceived(offset: 0, limit: Int.max) } diff --git a/Tests/TestUtils/MockTransactionRepository.swift b/Tests/TestUtils/MockTransactionRepository.swift index 7504238a..158cef84 100644 --- a/Tests/TestUtils/MockTransactionRepository.swift +++ b/Tests/TestUtils/MockTransactionRepository.swift @@ -73,6 +73,10 @@ extension MockTransactionRepository.Kind: Equatable {} // MARK: - TransactionRepository extension MockTransactionRepository: TransactionRepository { + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] { + [] + } + func findForResubmission(upTo: ZcashLightClientKit.BlockHeight) async throws -> [ZcashLightClientKit.ZcashTransaction.Overview] { [] } diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index 39d34d94..72af0b83 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -1947,6 +1947,30 @@ class SynchronizerMock: Synchronizer { } } + // MARK: - fetchTxidsWithMemoContaining + + var fetchTxidsWithMemoContainingSearchTermThrowableError: Error? + var fetchTxidsWithMemoContainingSearchTermCallsCount = 0 + var fetchTxidsWithMemoContainingSearchTermCalled: Bool { + return fetchTxidsWithMemoContainingSearchTermCallsCount > 0 + } + var fetchTxidsWithMemoContainingSearchTermReceivedSearchTerm: String? + var fetchTxidsWithMemoContainingSearchTermReturnValue: [Data]! + var fetchTxidsWithMemoContainingSearchTermClosure: ((String) async throws -> [Data])? + + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] { + if let error = fetchTxidsWithMemoContainingSearchTermThrowableError { + throw error + } + fetchTxidsWithMemoContainingSearchTermCallsCount += 1 + fetchTxidsWithMemoContainingSearchTermReceivedSearchTerm = searchTerm + if let closure = fetchTxidsWithMemoContainingSearchTermClosure { + return try await closure(searchTerm) + } else { + return fetchTxidsWithMemoContainingSearchTermReturnValue + } + } + // MARK: - rewind var rewindCallsCount = 0 @@ -2135,6 +2159,30 @@ class TransactionRepositoryMock: TransactionRepository { } } + // MARK: - fetchTxidsWithMemoContaining + + var fetchTxidsWithMemoContainingSearchTermThrowableError: Error? + var fetchTxidsWithMemoContainingSearchTermCallsCount = 0 + var fetchTxidsWithMemoContainingSearchTermCalled: Bool { + return fetchTxidsWithMemoContainingSearchTermCallsCount > 0 + } + var fetchTxidsWithMemoContainingSearchTermReceivedSearchTerm: String? + var fetchTxidsWithMemoContainingSearchTermReturnValue: [Data]! + var fetchTxidsWithMemoContainingSearchTermClosure: ((String) async throws -> [Data])? + + func fetchTxidsWithMemoContaining(searchTerm: String) async throws -> [Data] { + if let error = fetchTxidsWithMemoContainingSearchTermThrowableError { + throw error + } + fetchTxidsWithMemoContainingSearchTermCallsCount += 1 + fetchTxidsWithMemoContainingSearchTermReceivedSearchTerm = searchTerm + if let closure = fetchTxidsWithMemoContainingSearchTermClosure { + return try await closure(searchTerm) + } else { + return fetchTxidsWithMemoContainingSearchTermReturnValue + } + } + // MARK: - find var findRawIDThrowableError: Error?