diff --git a/CHANGELOG.md b/CHANGELOG.md index 25ca6d3c..14aade1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this library adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). # Unreleased + ## Added -- `Synchronizer.getExchangeRateUSD() -> NSDecimalNumber`, which fetches the latest USD/ZEC - exchange rate. Prices are queried over Tor (to hide the wallet's IP address) on Binance, - Coinbase, and Gemini. +- `Synchronizer.exchangeRateUSDStream: AnyPublisher`, + which returns the currently-cached USD/ZEC exchange rate, or `nil` if it has not yet been + fetched. +- `Synchronizer.refreshExchangeRateUSD()`, which refreshes the rate returned by + `Synchronizer.exchangeRateUSDStream`. Prices are queried over Tor (to hide the wallet's + IP address). # 2.1.12 - 2024-07-04 diff --git a/Example/ZcashLightClientSample/ZcashLightClientSample/Get Balance/GetBalanceViewController.swift b/Example/ZcashLightClientSample/ZcashLightClientSample/Get Balance/GetBalanceViewController.swift index 37c7a902..89fe2cf9 100644 --- a/Example/ZcashLightClientSample/ZcashLightClientSample/Get Balance/GetBalanceViewController.swift +++ b/Example/ZcashLightClientSample/ZcashLightClientSample/Get Balance/GetBalanceViewController.swift @@ -8,25 +8,49 @@ import UIKit import ZcashLightClientKit +import Combine class GetBalanceViewController: UIViewController { @IBOutlet weak var balance: UILabel! @IBOutlet weak var verified: UILabel! + var cancellable: AnyCancellable? + + var accountBalance: AccountBalance? + var rate: FiatCurrencyResult? + override func viewDidLoad() { super.viewDidLoad() let synchronizer = AppDelegate.shared.sharedSynchronizer self.title = "Account 0 Balance" - Task { @MainActor in - let balance = try? await synchronizer.getAccountBalance() - let balanceText = (balance?.saplingBalance.total().formattedString) ?? "0.0" - let verifiedText = (balance?.saplingBalance.spendableValue.formattedString) ?? "0.0" - let usdZecRate = try await synchronizer.getExchangeRateUSD() - let usdBalance = (balance?.saplingBalance.total().decimalValue ?? 0).multiplying(by: usdZecRate) - let usdVerified = (balance?.saplingBalance.spendableValue.decimalValue ?? 0).multiplying(by: usdZecRate) - self.balance.text = "\(balanceText) ZEC\n\(usdBalance) USD\n\n(\(usdZecRate) USD/ZEC)" - self.verified.text = "\(verifiedText) ZEC\n\(usdVerified) USD" + Task { @MainActor [weak self] in + self?.accountBalance = try? await synchronizer.getAccountBalance() + self?.updateLabels() + } + + cancellable = synchronizer.exchangeRateUSDStream.sink { [weak self] result in + self?.rate = result + self?.updateLabels() + } + + synchronizer.refreshExchangeRateUSD() + } + + func updateLabels() { + DispatchQueue.main.async { [weak self] in + let balanceText = (self?.accountBalance?.saplingBalance.total().formattedString) ?? "0.0" + let verifiedText = (self?.accountBalance?.saplingBalance.spendableValue.formattedString) ?? "0.0" + + if let usdZecRate = self?.rate { + let usdBalance = (self?.accountBalance?.saplingBalance.total().decimalValue ?? 0).multiplying(by: usdZecRate.rate) + let usdVerified = (self?.accountBalance?.saplingBalance.spendableValue.decimalValue ?? 0).multiplying(by: usdZecRate.rate) + self?.balance.text = "\(balanceText) ZEC\n\(usdBalance) USD\n\n(\(usdZecRate.rate) USD/ZEC)" + self?.verified.text = "\(verifiedText) ZEC\n\(usdVerified) USD" + } else { + self?.balance.text = "\(balanceText) ZEC" + self?.verified.text = "\(verifiedText) ZEC" + } } } } diff --git a/Sources/ZcashLightClientKit/ClosureSynchronizer.swift b/Sources/ZcashLightClientKit/ClosureSynchronizer.swift index 02fcc6d4..00d2583a 100644 --- a/Sources/ZcashLightClientKit/ClosureSynchronizer.swift +++ b/Sources/ZcashLightClientKit/ClosureSynchronizer.swift @@ -129,8 +129,7 @@ public protocol ClosureSynchronizer { func getAccountBalance(accountIndex: Int, completion: @escaping (Result) -> Void) - /// Fetches the latest ZEC-USD exchange rate. - func getExchangeRateUSD(completion: @escaping (Result) -> Void) + func refreshExchangeRateUSD() /* It can be missleading that these two methods are returning Publisher even this protocol is closure based. Reason is that Synchronizer doesn't diff --git a/Sources/ZcashLightClientKit/CombineSynchronizer.swift b/Sources/ZcashLightClientKit/CombineSynchronizer.swift index bbd24c0c..29579198 100644 --- a/Sources/ZcashLightClientKit/CombineSynchronizer.swift +++ b/Sources/ZcashLightClientKit/CombineSynchronizer.swift @@ -129,10 +129,7 @@ public protocol CombineSynchronizer { func refreshUTXOs(address: TransparentAddress, from height: BlockHeight) -> SinglePublisher - func getAccountBalance(accountIndex: Int) -> SinglePublisher - - /// Fetches the latest ZEC-USD exchange rate. - func getExchangeRateUSD() -> SinglePublisher + func refreshExchangeRateUSD() func rewind(_ policy: RewindPolicy) -> CompletablePublisher func wipe() -> CompletablePublisher diff --git a/Sources/ZcashLightClientKit/Model/FiatCurrencyResult.swift b/Sources/ZcashLightClientKit/Model/FiatCurrencyResult.swift new file mode 100644 index 00000000..06af28e7 --- /dev/null +++ b/Sources/ZcashLightClientKit/Model/FiatCurrencyResult.swift @@ -0,0 +1,13 @@ +// +// FiatCurrencyResult.swift +// +// +// Created by Lukáš Korba on 31.07.2024. +// + +import Foundation + +public struct FiatCurrencyResult: Equatable { + public let rate: NSDecimalNumber + public let date: Date +} diff --git a/Sources/ZcashLightClientKit/Synchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer.swift index e584294f..c09ee580 100644 --- a/Sources/ZcashLightClientKit/Synchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer.swift @@ -101,6 +101,9 @@ public protocol Synchronizer: AnyObject { /// This stream is backed by `PassthroughSubject`. Check `SynchronizerEvent` to see which events may be emitted. var eventStream: AnyPublisher { get } + /// This stream emits the latest known USD/ZEC exchange rate, paired with the time it was queried. See `FiatCurrencyResult`. + var exchangeRateUSDStream: AnyPublisher { get } + /// Initialize the wallet. The ZIP-32 seed bytes can optionally be passed to perform /// database migrations. most of the times the seed won't be needed. If they do and are /// not provided this will fail with `InitializationResult.seedRequired`. It could @@ -309,8 +312,8 @@ public protocol Synchronizer: AnyObject { /// - Returns: `AccountBalance`, struct that holds sapling and unshielded balances or `nil` when no account is associated with `accountIndex` func getAccountBalance(accountIndex: Int) async throws -> AccountBalance? - /// Fetches the latest ZEC-USD exchange rate. - func getExchangeRateUSD() async throws -> NSDecimalNumber + /// Fetches the latest ZEC-USD exchange rate and updates `exchangeRateUSDSubject`. + func refreshExchangeRateUSD() /// Rescans the known blocks with the current keys. /// diff --git a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift index 2ce9f2f5..5c54ac69 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/ClosureSDKSynchronizer.swift @@ -194,10 +194,8 @@ extension ClosureSDKSynchronizer: ClosureSynchronizer { } } - public func getExchangeRateUSD(completion: @escaping (Result) -> Void) { - AsyncToClosureGateway.executeThrowingAction(completion) { - try await self.synchronizer.getExchangeRateUSD() - } + public func refreshExchangeRateUSD() { + synchronizer.refreshExchangeRateUSD() } /* diff --git a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift index 9f09333c..fd910bf8 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/CombineSDKSynchronizer.swift @@ -196,10 +196,8 @@ extension CombineSDKSynchronizer: CombineSynchronizer { } } - public func getExchangeRateUSD() -> SinglePublisher { - AsyncToCombineGateway.executeThrowingAction() { - try await self.synchronizer.getExchangeRateUSD() - } + public func refreshExchangeRateUSD() { + synchronizer.refreshExchangeRateUSD() } public func rewind(_ policy: RewindPolicy) -> CompletablePublisher { synchronizer.rewind(policy) } diff --git a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift index 27a8f52c..c32407bd 100644 --- a/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift +++ b/Sources/ZcashLightClientKit/Synchronizer/SDKSynchronizer.swift @@ -22,6 +22,9 @@ public class SDKSynchronizer: Synchronizer { private let eventSubject = PassthroughSubject() public var eventStream: AnyPublisher { eventSubject.eraseToAnyPublisher() } + private let exchangeRateUSDSubject = CurrentValueSubject(nil) + public var exchangeRateUSDStream: AnyPublisher { exchangeRateUSDSubject.eraseToAnyPublisher() } + let metrics: SDKMetrics public let logger: Logger @@ -508,21 +511,16 @@ public class SDKSynchronizer: Synchronizer { try await initializer.rustBackend.getWalletSummary()?.accountBalances[UInt32(accountIndex)] } - public func getExchangeRateUSD() async throws -> NSDecimalNumber { - logger.info("Bootstrapping Tor client for fetching exchange rates") - let tor: TorClient - do { - tor = try await TorClient(torDir: initializer.torDirURL) - } catch { - logger.error("failed to bootstrap Tor client: \(error)") - throw error - } + /// Fetches the latest ZEC-USD exchange rate. + public func refreshExchangeRateUSD() { + Task { + logger.info("Bootstrapping Tor client for fetching exchange rates") - do { - return try await tor.getExchangeRateUSD() - } catch { - logger.error("Failed to fetch exchange rate through Tor: \(error)") - throw error + guard let tor = try? await TorClient(torDir: initializer.torDirURL) else { + return + } + + exchangeRateUSDSubject.send(try? await tor.getExchangeRateUSD()) } } diff --git a/Sources/ZcashLightClientKit/Tor/TorClient.swift b/Sources/ZcashLightClientKit/Tor/TorClient.swift index cb14b394..d5e51e36 100644 --- a/Sources/ZcashLightClientKit/Tor/TorClient.swift +++ b/Sources/ZcashLightClientKit/Tor/TorClient.swift @@ -36,13 +36,16 @@ public class TorClient { zcashlc_free_tor_runtime(runtime) } - public func getExchangeRateUSD() async throws -> NSDecimalNumber { + public func getExchangeRateUSD() async throws -> FiatCurrencyResult { let rate = zcashlc_get_exchange_rate_usd(runtime) if rate.is_sign_negative { throw ZcashError.rustTorClientGet(lastErrorMessage(fallback: "`TorClient.get` failed with unknown error")) } - return NSDecimalNumber(mantissa: rate.mantissa, exponent: rate.exponent, isNegative: rate.is_sign_negative) + return FiatCurrencyResult( + rate: NSDecimalNumber(mantissa: rate.mantissa, exponent: rate.exponent, isNegative: rate.is_sign_negative), + date: Date() + ) } } diff --git a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift index 5cde510e..9a7856e6 100644 --- a/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift +++ b/Tests/TestUtils/Sourcery/GeneratedMocks/AutoMockable.generated.swift @@ -1311,6 +1311,10 @@ class SynchronizerMock: Synchronizer { get { return underlyingEventStream } } var underlyingEventStream: AnyPublisher! + var exchangeRateUSDStream: AnyPublisher { + get { return underlyingExchangeRateUSDStream } + } + var underlyingExchangeRateUSDStream: AnyPublisher! var transactions: [ZcashTransaction.Overview] { get async { return underlyingTransactions } } @@ -1798,26 +1802,17 @@ class SynchronizerMock: Synchronizer { } } - // MARK: - getExchangeRateUSD + // MARK: - refreshExchangeRateUSD - var getExchangeRateUSDThrowableError: Error? - var getExchangeRateUSDCallsCount = 0 - var getExchangeRateUSDCalled: Bool { - return getExchangeRateUSDCallsCount > 0 + var refreshExchangeRateUSDCallsCount = 0 + var refreshExchangeRateUSDCalled: Bool { + return refreshExchangeRateUSDCallsCount > 0 } - var getExchangeRateUSDReturnValue: NSDecimalNumber! - var getExchangeRateUSDClosure: (() async throws -> NSDecimalNumber)? + var refreshExchangeRateUSDClosure: (() -> Void)? - func getExchangeRateUSD() async throws -> NSDecimalNumber { - if let error = getExchangeRateUSDThrowableError { - throw error - } - getExchangeRateUSDCallsCount += 1 - if let closure = getExchangeRateUSDClosure { - return try await closure() - } else { - return getExchangeRateUSDReturnValue - } + func refreshExchangeRateUSD() { + refreshExchangeRateUSDCallsCount += 1 + refreshExchangeRateUSDClosure!() } // MARK: - rewind