ZcashLightClientKit/ZcashLightClientKit/UIKit/Synchronizer/SDKSynchronizer.swift

656 lines
26 KiB
Swift
Raw Normal View History

//
// SDKSynchronizer.swift
// ZcashLightClientKit
//
// Created by Francisco Gindre on 11/6/19.
// Copyright © 2019 Electric Coin Company. All rights reserved.
//
import Foundation
import UIKit
public extension Notification.Name {
/**
Notification is posted whenever transactions are updated
- Important: not yet posted
*/
static let transactionsUpdated = Notification.Name("SDKSyncronizerTransactionUpdated")
/**
Posted when the synchronizer is started.
*/
static let synchronizerStarted = Notification.Name("SDKSyncronizerStarted")
/**
Posted when there are progress updates.
- Note: Query userInfo object for NotificationKeys.progress for Float progress percentage and NotificationKeys.blockHeight for the current progress height
*/
static let synchronizerProgressUpdated = Notification.Name("SDKSyncronizerProgressUpdated")
/**
Posted when the synchronizer is synced to latest height
*/
static let synchronizerSynced = Notification.Name("SDKSyncronizerSynced")
/**
Posted when the synchronizer is stopped
*/
static let synchronizerStopped = Notification.Name("SDKSyncronizerStopped")
/**
Posted when the synchronizer loses connection
*/
static let synchronizerDisconnected = Notification.Name("SDKSyncronizerDisconnected")
/**
Posted when the synchronizer starts syncing
*/
static let synchronizerSyncing = Notification.Name("SDKSyncronizerSyncing")
/**
Posted when the synchronizer finds a pendingTransaction that hast been newly mined
- Note: query userInfo on NotificationKeys.minedTransaction for the transaction
*/
static let synchronizerMinedTransaction = Notification.Name("synchronizerMinedTransaction")
/**
Posted when the synchronizer finds a mined transaction
- Note: query userInfo on NotificationKeys.foundTransactions for the [ConfirmedTransactionEntity]. This notification could arrive in a background thread.
*/
static let synchronizerFoundTransactions = Notification.Name("synchronizerFoundTransactions")
/**
Posted when the synchronizer presents an error
- Note: query userInfo on NotificationKeys.error for an error
*/
2019-12-19 05:04:50 -08:00
static let synchronizerFailed = Notification.Name("SDKSynchronizerFailed")
}
/**
2020-03-26 07:27:55 -07:00
Synchronizer implementation for UIKit and iOS 12+
*/
public class SDKSynchronizer: Synchronizer {
public struct NotificationKeys {
public static let progress = "SDKSynchronizer.progress"
public static let blockHeight = "SDKSynchronizer.blockHeight"
public static let minedTransaction = "SDKSynchronizer.minedTransaction"
public static let foundTransactions = "SDKSynchronizer.foundTransactions"
2019-12-19 05:04:50 -08:00
public static let error = "SDKSynchronizer.error"
}
public private(set) var status: Status {
didSet {
notify(status: status)
}
}
public private(set) var progress: Float = 0.0
public private(set) var blockProcessor: CompactBlockProcessor
public private(set) var initializer: Initializer
private var transactionManager: OutboundTransactionManager
private var transactionRepository: TransactionRepository
2020-12-11 12:15:29 -08:00
private var utxoRepository: UnspentTransactionOutputRepository
/**
Creates an SDKSynchronizer instance
- Parameter initializer: a wallet Initializer object
*/
public convenience init(initializer: Initializer) throws {
try self.init(status: .disconnected,
initializer: initializer,
transactionManager: try OutboundTransactionManagerBuilder.build(initializer: initializer),
2020-12-11 12:15:29 -08:00
transactionRepository: initializer.transactionRepository,
utxoRepository: try UTXORepositoryBuilder.build(initializer: initializer),
blockProcessor: CompactBlockProcessor(initializer: initializer))
}
init(status: Status,
initializer: Initializer,
transactionManager: OutboundTransactionManager,
2020-12-11 12:15:29 -08:00
transactionRepository: TransactionRepository,
utxoRepository: UnspentTransactionOutputRepository,
blockProcessor: CompactBlockProcessor) throws {
self.status = status
self.initializer = initializer
self.transactionManager = transactionManager
self.transactionRepository = transactionRepository
2020-12-11 12:15:29 -08:00
self.utxoRepository = utxoRepository
self.blockProcessor = blockProcessor
self.subscribeToProcessorNotifications(self.blockProcessor)
}
deinit {
NotificationCenter.default.removeObserver(self)
self.blockProcessor.stop()
}
2021-04-02 15:18:16 -07:00
public func initialize(unifiedViewingKeys: [UnifiedViewingKey], walletBirthday: BlockHeight) throws {
try self.initializer.initialize(unifiedViewingKeys: unifiedViewingKeys, walletBirthday: walletBirthday)
try self.blockProcessor.setStartHeight(WalletBirthday.birthday(with: walletBirthday).height)
}
/**
Starts the synchronizer
- Throws: CompactBlockProcessorError when failures occur
*/
public func start(retry: Bool = false) throws {
2021-04-02 15:18:16 -07:00
guard status == .stopped || status == .disconnected || status == .synced else {
2020-03-26 07:27:55 -07:00
assert(true,"warning: synchronizer started when already started") // TODO: remove this assertion sometime in the near future
return
}
do {
try blockProcessor.start(retry: retry)
} catch {
throw mapError(error)
}
}
/**
Stops the synchronizer
*/
public func stop() {
guard status != .stopped, status != .disconnected else { return }
blockProcessor.stop(cancelTasks: true)
}
private func subscribeToProcessorNotifications(_ processor: CompactBlockProcessor) {
let center = NotificationCenter.default
center.addObserver(self,
selector: #selector(processorUpdated(_:)),
name: Notification.Name.blockProcessorUpdated,
object: processor)
center.addObserver(self,
selector: #selector(processorStartedDownloading(_:)),
name: Notification.Name.blockProcessorStartedDownloading,
object: processor)
center.addObserver(self,
selector: #selector(processorStartedValidating(_:)),
name: Notification.Name.blockProcessorStartedValidating,
object: processor)
center.addObserver(self,
selector: #selector(processorStartedScanning(_:)),
name: Notification.Name.blockProcessorStartedScanning,
object: processor)
center.addObserver(self,
selector: #selector(processorStopped(_:)),
name: Notification.Name.blockProcessorStopped,
object: processor)
center.addObserver(self, selector: #selector(processorFailed(_:)),
name: Notification.Name.blockProcessorFailed,
object: processor)
center.addObserver(self,
selector: #selector(processorIdle(_:)),
name: Notification.Name.blockProcessorIdle,
object: processor)
center.addObserver(self,
selector: #selector(processorFinished(_:)),
name: Notification.Name.blockProcessorFinished,
object: processor)
center.addObserver(self,
selector: #selector(processorTransitionUnknown(_:)),
name: Notification.Name.blockProcessorUnknownTransition,
object: processor)
center.addObserver(self,
selector: #selector(reorgDetected(_:)),
name: Notification.Name.blockProcessorHandledReOrg,
object: processor)
center.addObserver(self,
selector: #selector(transactionsFound(_:)),
name: Notification.Name.blockProcessorFoundTransactions,
object: processor)
}
// MARK: Block Processor notifications
@objc func transactionsFound(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let foundTransactions = userInfo[CompactBlockProcessorNotificationKey.foundTransactions] as? [ConfirmedTransactionEntity] else {
return
}
NotificationCenter.default.post(name: .synchronizerFoundTransactions, object: self, userInfo: [ NotificationKeys.foundTransactions : foundTransactions])
}
@objc func reorgDetected(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let progress = userInfo[CompactBlockProcessorNotificationKey.reorgHeight] as? BlockHeight,
let rewindHeight = userInfo[CompactBlockProcessorNotificationKey.rewindHeight] as? BlockHeight else {
LoggerProxy.debug("error processing reorg notification")
return }
LoggerProxy.debug("handling reorg at: \(progress) with rewind height: \(rewindHeight)")
do {
try transactionManager.handleReorg(at: rewindHeight)
} catch {
LoggerProxy.debug("error handling reorg: \(error)")
2019-12-19 05:04:50 -08:00
notifyFailure(error)
}
}
@objc func processorUpdated(_ notification: Notification) {
guard let userInfo = notification.userInfo,
let progress = userInfo[CompactBlockProcessorNotificationKey.progress] as? Float,
let height = userInfo[CompactBlockProcessorNotificationKey.progressHeight] as? BlockHeight else {
return
}
self.progress = progress
self.notify(progress: progress, height: height)
}
@objc func processorStartedDownloading(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.status = .syncing
}
}
@objc func processorStartedValidating(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.status = .syncing
}
}
@objc func processorStartedScanning(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.status = .syncing
}
}
@objc func processorStopped(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.status = .stopped
}
}
@objc func processorFailed(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if let error = notification.userInfo?[CompactBlockProcessorNotificationKey.error] as? Error {
self.notifyFailure(error)
} else {
self.notifyFailure(CompactBlockProcessorError.generalError(message: "This is strange. processorFailed Call received no error message"))
}
self.status = .disconnected
}
}
@objc func processorIdle(_ notification: Notification) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.status = .disconnected
}
}
@objc func processorFinished(_ notification: Notification) {
// FIX: Pending transaction updates fail if done from another thread. Improvement needed: explicitly define queues for sql repositories
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.refreshPendingTransactions()
self.status = .synced
}
}
@objc func processorTransitionUnknown(_ notification: Notification) {
self.status = .disconnected
}
// MARK: Synchronizer methods
public func sendToAddress(spendingKey: String, zatoshi: Int64, toAddress: String, memo: String?, from accountIndex: Int, resultBlock: @escaping (Result<PendingTransactionEntity, Error>) -> Void) {
2020-10-08 10:00:27 -07:00
initializer.downloadParametersIfNeeded { (downloadResult) in
DispatchQueue.main.async { [weak self] in
switch downloadResult {
case .success:
self?.createToAddress(spendingKey: spendingKey, zatoshi: zatoshi, toAddress: toAddress, memo: memo, from: accountIndex, resultBlock: resultBlock)
case .failure(let error):
resultBlock(.failure(SynchronizerError.parameterMissing(underlyingError: error)))
}
}
}
}
2020-12-23 15:01:09 -08:00
public func shieldFunds(spendingKey: String, transparentSecretKey: String, memo: String?, from accountIndex: Int, resultBlock: @escaping (Result<PendingTransactionEntity, Error>) -> Void) {
// let's see if there are funds to shield
let derivationTool = DerivationTool.default
do {
let tAddr = try derivationTool.deriveTransparentAddressFromPrivateKey(transparentSecretKey)
let tBalance = try utxoRepository.balance(address: tAddr, latestHeight: self.latestDownloadedHeight())
2020-12-23 15:01:09 -08:00
guard tBalance.verified >= ZcashSDK.shieldingThreshold else {
2020-12-23 15:01:09 -08:00
resultBlock(.failure(ShieldFundsError.insuficientTransparentFunds))
return
}
let vk = try derivationTool.deriveViewingKey(spendingKey: spendingKey)
let zAddr = try derivationTool.deriveShieldedAddress(viewingKey: vk)
let shieldingSpend = try transactionManager.initSpend(zatoshi: Int(tBalance.verified), toAddress: zAddr, memo: memo, from: 0)
2020-12-23 15:01:09 -08:00
transactionManager.encodeShieldingTransaction(spendingKey: spendingKey, tsk: transparentSecretKey, pendingTransaction: shieldingSpend) {[weak self] (result) in
guard let self = self else { return }
switch result {
case .success(let tx):
self.transactionManager.submit(pendingTransaction: tx) { (submitResult) in
switch submitResult {
case .success(let submittedTx):
resultBlock(.success(submittedTx))
case .failure(let submissionError):
DispatchQueue.main.async {
resultBlock(.failure(submissionError))
}
}
}
case .failure(let error):
resultBlock(.failure(error))
}
}
} catch {
resultBlock(.failure(error))
return
}
}
2020-10-08 10:00:27 -07:00
func createToAddress(spendingKey: String, zatoshi: Int64, toAddress: String, memo: String?, from accountIndex: Int, resultBlock: @escaping (Result<PendingTransactionEntity, Error>) -> Void) {
do {
let spend = try transactionManager.initSpend(zatoshi: Int(zatoshi), toAddress: toAddress, memo: memo, from: accountIndex)
transactionManager.encode(spendingKey: spendingKey, pendingTransaction: spend) { [weak self] (result) in
guard let self = self else { return }
switch result {
case .success(let tx):
self.transactionManager.submit(pendingTransaction: tx) { (submitResult) in
switch submitResult {
case .success(let submittedTx):
resultBlock(.success(submittedTx))
case .failure(let submissionError):
DispatchQueue.main.async {
resultBlock(.failure(submissionError))
}
}
}
case .failure(let error):
resultBlock(.failure(error))
}
}
} catch {
resultBlock(.failure(error))
}
}
public func getShieldedAddress(accountIndex: Int) -> String {
initializer.getAddress(index: accountIndex) ?? ""
}
public func cancelSpend(transaction: PendingTransactionEntity) -> Bool {
transactionManager.cancel(pendingTransaction: transaction)
}
public func allReceivedTransactions() throws -> [ConfirmedTransactionEntity] {
try transactionRepository.findAllReceivedTransactions(offset: 0, limit: Int.max) ?? [ConfirmedTransactionEntity]()
}
public func allPendingTransactions() throws -> [PendingTransactionEntity] {
try transactionManager.allPendingTransactions() ?? [PendingTransactionEntity]()
}
public func allClearedTransactions() throws -> [ConfirmedTransactionEntity] {
try transactionRepository.findAll(offset: 0, limit: Int.max) ?? [ConfirmedTransactionEntity]()
}
public func allSentTransactions() throws -> [ConfirmedTransactionEntity] {
try transactionRepository.findAllSentTransactions(offset: 0, limit: Int.max) ?? [ConfirmedTransactionEntity]()
}
public func allConfirmedTransactions(from transaction: ConfirmedTransactionEntity?, limit: Int) throws -> [ConfirmedTransactionEntity]? {
try transactionRepository.findAll(from: transaction, limit: limit)
}
public func paginatedTransactions(of kind: TransactionKind = .all) -> PaginatedTransactionRepository {
PagedTransactionRepositoryBuilder.build(initializer: initializer, kind: .all)
}
2020-10-08 10:00:27 -07:00
public func latestDownloadedHeight() throws -> BlockHeight {
2020-10-06 16:35:17 -07:00
try initializer.downloader.lastDownloadedBlockHeight()
}
public func latestHeight(result: @escaping (Result<BlockHeight, Error>) -> Void) {
initializer.downloader.latestBlockHeight(result: result)
}
public func latestHeight() throws -> BlockHeight {
try initializer.downloader.latestBlockHeight()
}
2020-12-11 12:15:29 -08:00
public func latestUTXOs(address: String, result: @escaping (Result<[UnspentTransactionOutputEntity], Error>) -> Void) {
guard initializer.isValidTransparentAddress(address) else {
result(.failure(SynchronizerError.generalError(message: "invalid t-address")))
return
}
initializer.lightWalletService.fetchUTXOs(for: address, height: ZcashSDK.SAPLING_ACTIVATION_HEIGHT, result: { [weak self] r in
2020-12-11 12:15:29 -08:00
guard let self = self else { return }
switch r {
case .success(let utxos):
do {
try self.utxoRepository.clearAll(address: address)
try self.utxoRepository.store(utxos: utxos)
result(.success(utxos))
} catch {
result(.failure(SynchronizerError.generalError(message: "\(error)")))
}
case .failure(let error):
result(.failure(SynchronizerError.connectionFailed(message: error)))
}
})
}
2021-04-01 07:27:26 -07:00
public func refreshUTXOs(address: String, from height: BlockHeight = ZcashSDK.SAPLING_ACTIVATION_HEIGHT, result: @escaping (Result<RefreshedUTXOs, Error>) -> Void) {
self.blockProcessor.downloadUTXOs(tAddress: address, startHeight: height, result: result)
}
public func getShieldedBalance(accountIndex: Int = 0) -> Int64 {
initializer.getBalance(account: accountIndex)
}
public func getShieldedVerifiedBalance(accountIndex: Int = 0) -> Int64 {
initializer.getVerifiedBalance(account: accountIndex)
}
/**
gets the last stored unshielded balance
*/
2021-02-17 15:02:25 -08:00
public func getTransparentBalance(address: String) throws -> WalletBalance {
do {
return try self.blockProcessor.utxoCacheBalance(tAddress: address)
} catch {
throw SynchronizerError.uncategorized(underlyingError: error)
}
}
public func rewind(_ policy: RewindPolicy) throws {
self.stop()
var height: BlockHeight?
switch policy {
case .birthday:
let birthday = self.blockProcessor.config.walletBirthday
height = birthday
case .height(let rewindHeight):
height = rewindHeight
case .transaction(let tx):
guard let txHeight = tx.anchor else {
throw SynchronizerError.rewindErrorUnknownArchorHeight
}
height = txHeight
}
guard let h = height else {
throw SynchronizerError.rewindErrorUnknownArchorHeight
}
do {
try self.blockProcessor.rewindTo(h)
try self.transactionManager.handleReorg(at: h)
} catch {
throw SynchronizerError.rewindError(underlyingError: error)
}
}
// MARK: notify state
private func notify(progress: Float, height: BlockHeight) {
NotificationCenter.default.post(name: Notification.Name.synchronizerProgressUpdated, object: self, userInfo: [
NotificationKeys.progress : progress,
NotificationKeys.blockHeight : height])
}
private func notify(status: Status) {
switch status {
case .disconnected:
NotificationCenter.default.post(name: Notification.Name.synchronizerDisconnected, object: self)
case .stopped:
NotificationCenter.default.post(name: Notification.Name.synchronizerStopped, object: self)
case .synced:
NotificationCenter.default.post(name: Notification.Name.synchronizerSynced, object: self)
case .syncing:
NotificationCenter.default.post(name: Notification.Name.synchronizerSyncing, object: self)
}
}
// MARK: book keeping
private func updateMinedTransactions() throws {
try transactionManager.allPendingTransactions()?.filter( { $0.isSubmitSuccess && !$0.isMined } ).forEach( { pendingTx in
guard let rawId = pendingTx.rawTransactionId else { return }
let tx = try transactionRepository.findBy(rawId: rawId)
guard let minedHeight = tx?.minedHeight else { return }
let minedTx = try transactionManager.applyMinedHeight(pendingTransaction: pendingTx, minedHeight: minedHeight)
notifyMinedTransaction(minedTx)
})
}
private func removeConfirmedTransactions() throws {
let latestHeight = try transactionRepository.lastScannedHeight()
try transactionManager.allPendingTransactions()?.filter( {
$0.minedHeight > 0 && abs($0.minedHeight - latestHeight) >= ZcashSDK.DEFAULT_STALE_TOLERANCE }
).forEach( {
try transactionManager.delete(pendingTransaction: $0)
} )
}
private func refreshPendingTransactions() {
do {
try updateMinedTransactions()
try removeConfirmedTransactions()
} catch {
LoggerProxy.debug("error refreshing pending transactions: \(error)")
}
}
private func notifyMinedTransaction(_ tx: PendingTransactionEntity) {
DispatchQueue.main.async {
[weak self] in
guard let self = self else { return }
NotificationCenter.default.post(name: Notification.Name.synchronizerMinedTransaction, object: self, userInfo: [NotificationKeys.minedTransaction : tx])
}
}
2019-12-19 05:04:50 -08:00
private func mapError(_ error: Error) -> Error {
if let compactBlockProcessorError = error as? CompactBlockProcessorError {
switch compactBlockProcessorError {
case .dataDbInitFailed(let path):
return SynchronizerError.initFailed(message: "DataDb init failed at path: \(path)")
case .connectionError(let message):
return SynchronizerError.connectionFailed(message: message)
case .invalidConfiguration:
return SynchronizerError.generalError(message: "Invalid Configuration")
case .missingDbPath(let path):
return SynchronizerError.initFailed(message: "missing Db path: \(path)")
case .generalError(let message):
return SynchronizerError.generalError(message: message)
case .maxAttemptsReached(attempts: let attempts):
return SynchronizerError.maxRetryAttemptsReached(attempts: attempts)
case .grpcError(let statusCode, let message):
return SynchronizerError.connectionError(status: statusCode, message: message)
case .connectionTimeout:
return SynchronizerError.networkTimeout
case .unspecifiedError(let underlyingError):
return SynchronizerError.uncategorized(underlyingError: underlyingError)
case .criticalError:
return SynchronizerError.criticalError
}
}
return SynchronizerError.uncategorized(underlyingError: error)
}
2019-12-19 05:04:50 -08:00
private func notifyFailure(_ error: Error) {
2019-12-19 05:04:50 -08:00
DispatchQueue.main.async {
[weak self] in
guard let self = self else { return }
NotificationCenter.default.post(name: Notification.Name.synchronizerFailed, object: self, userInfo: [NotificationKeys.error : self.mapError(error)])
2019-12-19 05:04:50 -08:00
}
}
}
extension SDKSynchronizer {
public var pendingTransactions: [PendingTransactionEntity] {
(try? self.allPendingTransactions()) ?? [PendingTransactionEntity]()
}
public var clearedTransactions: [ConfirmedTransactionEntity] {
(try? self.allClearedTransactions()) ?? [ConfirmedTransactionEntity]()
}
public var sentTransactions: [ConfirmedTransactionEntity] {
(try? self.allSentTransactions()) ?? [ConfirmedTransactionEntity]()
}
public var receivedTransactions: [ConfirmedTransactionEntity] {
(try? self.allReceivedTransactions()) ?? [ConfirmedTransactionEntity]()
}
}